@lumenflow/cli 2.7.0 → 2.8.0

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 (81) hide show
  1. package/README.md +120 -105
  2. package/dist/__tests__/agent-spawn-coordination.test.js +451 -0
  3. package/dist/__tests__/commands/integrate.test.js +165 -0
  4. package/dist/__tests__/gates-config.test.js +0 -1
  5. package/dist/__tests__/hooks/enforcement.test.js +279 -0
  6. package/dist/__tests__/init-greenfield.test.js +247 -0
  7. package/dist/__tests__/init-quick-ref.test.js +0 -1
  8. package/dist/__tests__/init-template-portability.test.js +0 -1
  9. package/dist/__tests__/init.test.js +27 -0
  10. package/dist/__tests__/initiative-e2e.test.js +442 -0
  11. package/dist/__tests__/initiative-plan-replacement.test.js +0 -1
  12. package/dist/__tests__/memory-integration.test.js +333 -0
  13. package/dist/__tests__/release.test.js +1 -1
  14. package/dist/__tests__/safe-git.test.js +0 -1
  15. package/dist/__tests__/state-doctor.test.js +54 -0
  16. package/dist/__tests__/sync-templates.test.js +255 -0
  17. package/dist/__tests__/wu-create-required-fields.test.js +121 -0
  18. package/dist/__tests__/wu-done-auto-cleanup.test.js +135 -0
  19. package/dist/__tests__/wu-lifecycle-integration.test.js +388 -0
  20. package/dist/backlog-prune.js +0 -1
  21. package/dist/cli-entry-point.js +0 -1
  22. package/dist/commands/integrate.js +229 -0
  23. package/dist/docs-sync.js +46 -0
  24. package/dist/doctor.js +0 -2
  25. package/dist/gates.js +0 -7
  26. package/dist/hooks/enforcement-checks.js +209 -0
  27. package/dist/hooks/enforcement-generator.js +365 -0
  28. package/dist/hooks/enforcement-sync.js +243 -0
  29. package/dist/hooks/index.js +7 -0
  30. package/dist/init.js +256 -13
  31. package/dist/initiative-add-wu.js +0 -2
  32. package/dist/initiative-create.js +0 -3
  33. package/dist/initiative-edit.js +0 -5
  34. package/dist/initiative-plan.js +0 -1
  35. package/dist/initiative-remove-wu.js +0 -2
  36. package/dist/lane-health.js +0 -2
  37. package/dist/lane-suggest.js +0 -1
  38. package/dist/mem-checkpoint.js +0 -2
  39. package/dist/mem-cleanup.js +0 -2
  40. package/dist/mem-context.js +0 -3
  41. package/dist/mem-create.js +0 -2
  42. package/dist/mem-delete.js +0 -3
  43. package/dist/mem-inbox.js +0 -2
  44. package/dist/mem-index.js +0 -1
  45. package/dist/mem-init.js +0 -2
  46. package/dist/mem-profile.js +0 -1
  47. package/dist/mem-promote.js +0 -1
  48. package/dist/mem-ready.js +0 -2
  49. package/dist/mem-signal.js +0 -2
  50. package/dist/mem-start.js +0 -2
  51. package/dist/mem-summarize.js +0 -2
  52. package/dist/metrics-cli.js +1 -1
  53. package/dist/metrics-snapshot.js +1 -1
  54. package/dist/onboarding-smoke-test.js +0 -5
  55. package/dist/orchestrate-init-status.js +0 -1
  56. package/dist/orchestrate-initiative.js +0 -1
  57. package/dist/orchestrate-monitor.js +0 -1
  58. package/dist/plan-create.js +0 -2
  59. package/dist/plan-edit.js +0 -2
  60. package/dist/plan-link.js +0 -2
  61. package/dist/plan-promote.js +0 -2
  62. package/dist/signal-cleanup.js +0 -4
  63. package/dist/state-bootstrap.js +0 -1
  64. package/dist/state-cleanup.js +0 -4
  65. package/dist/state-doctor-fix.js +5 -8
  66. package/dist/state-doctor.js +0 -11
  67. package/dist/sync-templates.js +188 -34
  68. package/dist/wu-block.js +100 -48
  69. package/dist/wu-claim.js +1 -22
  70. package/dist/wu-cleanup.js +0 -1
  71. package/dist/wu-create.js +0 -2
  72. package/dist/wu-done-auto-cleanup.js +139 -0
  73. package/dist/wu-done.js +11 -4
  74. package/dist/wu-edit.js +0 -12
  75. package/dist/wu-preflight.js +0 -1
  76. package/dist/wu-prep.js +0 -1
  77. package/dist/wu-proto.js +0 -1
  78. package/dist/wu-spawn.js +0 -3
  79. package/dist/wu-unblock.js +0 -2
  80. package/dist/wu-validate.js +0 -1
  81. package/package.json +8 -7
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Memory Layer Integration Tests (WU-1363)
3
+ *
4
+ * Integration tests for memory layer operations:
5
+ * - AC3: mem:checkpoint, mem:signal, mem:inbox
6
+ *
7
+ * These tests validate the memory layer's ability to:
8
+ * - Create checkpoints for context preservation
9
+ * - Send and receive signals for agent coordination
10
+ * - Filter and query signals from inbox
11
+ *
12
+ * TDD: Tests written BEFORE implementation verification.
13
+ */
14
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
15
+ import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { tmpdir } from 'node:os';
18
+ import { createCheckpoint, createSignal, loadSignals, markSignalsAsRead } from '@lumenflow/memory';
19
+ // Test constants
20
+ const TEST_WU_ID = 'WU-9910';
21
+ const TEST_LANE = 'Framework: CLI';
22
+ // Session ID must be a valid UUID
23
+ const TEST_SESSION_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
24
+ /**
25
+ * Helper to create minimal memory directory structure
26
+ */
27
+ function createMemoryProject(baseDir) {
28
+ const dirs = ['.lumenflow/memory', '.lumenflow/state'];
29
+ for (const dir of dirs) {
30
+ mkdirSync(join(baseDir, dir), { recursive: true });
31
+ }
32
+ // Create minimal config
33
+ const configContent = `
34
+ version: 1
35
+ memory:
36
+ enabled: true
37
+ decay:
38
+ enabled: false
39
+ `;
40
+ writeFileSync(join(baseDir, '.lumenflow.config.yaml'), configContent);
41
+ }
42
+ describe('Memory Layer Integration Tests (WU-1363)', () => {
43
+ let tempDir;
44
+ let originalCwd;
45
+ beforeEach(() => {
46
+ tempDir = join(tmpdir(), `memory-integration-${Date.now()}-${Math.random().toString(36).slice(2)}`);
47
+ mkdirSync(tempDir, { recursive: true });
48
+ originalCwd = process.cwd();
49
+ createMemoryProject(tempDir);
50
+ });
51
+ afterEach(() => {
52
+ process.chdir(originalCwd);
53
+ if (existsSync(tempDir)) {
54
+ try {
55
+ rmSync(tempDir, { recursive: true, force: true });
56
+ }
57
+ catch {
58
+ // Ignore cleanup errors
59
+ }
60
+ }
61
+ });
62
+ describe('AC3: Integration tests for memory checkpoint, signal, inbox', () => {
63
+ describe('mem:checkpoint functionality', () => {
64
+ it('should create a checkpoint node with correct structure', async () => {
65
+ // Arrange
66
+ process.chdir(tempDir);
67
+ const note = 'Checkpoint before gates';
68
+ // Act
69
+ const result = await createCheckpoint(tempDir, {
70
+ note,
71
+ wuId: TEST_WU_ID,
72
+ sessionId: TEST_SESSION_ID,
73
+ });
74
+ // Assert
75
+ expect(result.success).toBe(true);
76
+ expect(result.checkpoint).toBeDefined();
77
+ expect(result.checkpoint.id).toMatch(/^mem-/);
78
+ expect(result.checkpoint.type).toBe('checkpoint');
79
+ expect(result.checkpoint.content).toContain(note);
80
+ expect(result.checkpoint.wu_id).toBe(TEST_WU_ID);
81
+ expect(result.checkpoint.session_id).toBe(TEST_SESSION_ID);
82
+ });
83
+ it('should include progress and nextSteps in metadata', async () => {
84
+ // Arrange
85
+ process.chdir(tempDir);
86
+ const progress = 'Completed AC1 and AC2';
87
+ const nextSteps = 'Run gates and complete wu:done';
88
+ // Act
89
+ const result = await createCheckpoint(tempDir, {
90
+ note: 'Progress checkpoint',
91
+ wuId: TEST_WU_ID,
92
+ progress,
93
+ nextSteps,
94
+ });
95
+ // Assert
96
+ expect(result.checkpoint.metadata).toBeDefined();
97
+ expect(result.checkpoint.metadata?.progress).toBe(progress);
98
+ expect(result.checkpoint.metadata?.nextSteps).toBe(nextSteps);
99
+ });
100
+ it('should persist checkpoint to memory store', async () => {
101
+ // Arrange
102
+ process.chdir(tempDir);
103
+ // Act
104
+ await createCheckpoint(tempDir, {
105
+ note: 'Persisted checkpoint',
106
+ wuId: TEST_WU_ID,
107
+ });
108
+ // Assert - Check memory file exists (memory store uses memory.jsonl)
109
+ const memoryFile = join(tempDir, '.lumenflow/memory/memory.jsonl');
110
+ expect(existsSync(memoryFile)).toBe(true);
111
+ const content = readFileSync(memoryFile, 'utf-8');
112
+ expect(content).toContain('Persisted checkpoint');
113
+ });
114
+ it('should validate note is required', async () => {
115
+ // Arrange
116
+ process.chdir(tempDir);
117
+ // Act & Assert
118
+ await expect(createCheckpoint(tempDir, {
119
+ note: '',
120
+ wuId: TEST_WU_ID,
121
+ })).rejects.toThrow(/empty/i);
122
+ });
123
+ it('should validate WU ID format if provided', async () => {
124
+ // Arrange
125
+ process.chdir(tempDir);
126
+ // Act & Assert
127
+ await expect(createCheckpoint(tempDir, {
128
+ note: 'Test checkpoint',
129
+ wuId: 'INVALID-ID',
130
+ })).rejects.toThrow(/WU/i);
131
+ });
132
+ });
133
+ describe('mem:signal functionality', () => {
134
+ it('should create a signal with correct structure', async () => {
135
+ // Arrange
136
+ process.chdir(tempDir);
137
+ const message = 'Starting implementation';
138
+ // Act
139
+ const result = await createSignal(tempDir, {
140
+ message,
141
+ wuId: TEST_WU_ID,
142
+ lane: TEST_LANE,
143
+ });
144
+ // Assert
145
+ expect(result.success).toBe(true);
146
+ expect(result.signal).toBeDefined();
147
+ expect(result.signal.id).toMatch(/^sig-/);
148
+ expect(result.signal.message).toBe(message);
149
+ expect(result.signal.wu_id).toBe(TEST_WU_ID);
150
+ expect(result.signal.lane).toBe(TEST_LANE);
151
+ expect(result.signal.read).toBe(false);
152
+ });
153
+ it('should persist signal to signals.jsonl', async () => {
154
+ // Arrange
155
+ process.chdir(tempDir);
156
+ // Act
157
+ await createSignal(tempDir, {
158
+ message: 'Persisted signal',
159
+ wuId: TEST_WU_ID,
160
+ });
161
+ // Assert
162
+ const signalsFile = join(tempDir, '.lumenflow/memory/signals.jsonl');
163
+ expect(existsSync(signalsFile)).toBe(true);
164
+ const content = readFileSync(signalsFile, 'utf-8');
165
+ expect(content).toContain('Persisted signal');
166
+ });
167
+ it('should validate message is required', async () => {
168
+ // Arrange
169
+ process.chdir(tempDir);
170
+ // Act & Assert
171
+ await expect(createSignal(tempDir, {
172
+ message: '',
173
+ wuId: TEST_WU_ID,
174
+ })).rejects.toThrow(/required/i);
175
+ });
176
+ it('should validate WU ID format if provided', async () => {
177
+ // Arrange
178
+ process.chdir(tempDir);
179
+ // Act & Assert
180
+ await expect(createSignal(tempDir, {
181
+ message: 'Test signal',
182
+ wuId: 'INVALID-123',
183
+ })).rejects.toThrow(/WU/i);
184
+ });
185
+ });
186
+ describe('mem:inbox functionality', () => {
187
+ it('should load all signals', async () => {
188
+ // Arrange
189
+ process.chdir(tempDir);
190
+ await createSignal(tempDir, { message: 'Signal 1', wuId: TEST_WU_ID });
191
+ await createSignal(tempDir, { message: 'Signal 2', wuId: TEST_WU_ID });
192
+ await createSignal(tempDir, { message: 'Signal 3', wuId: 'WU-9999' });
193
+ // Act
194
+ const signals = await loadSignals(tempDir);
195
+ // Assert
196
+ expect(signals).toHaveLength(3);
197
+ });
198
+ it('should filter signals by WU ID', async () => {
199
+ // Arrange
200
+ process.chdir(tempDir);
201
+ await createSignal(tempDir, { message: 'Signal 1', wuId: TEST_WU_ID });
202
+ await createSignal(tempDir, { message: 'Signal 2', wuId: TEST_WU_ID });
203
+ await createSignal(tempDir, { message: 'Other WU', wuId: 'WU-9999' });
204
+ // Act
205
+ const signals = await loadSignals(tempDir, { wuId: TEST_WU_ID });
206
+ // Assert
207
+ expect(signals).toHaveLength(2);
208
+ signals.forEach((sig) => expect(sig.wu_id).toBe(TEST_WU_ID));
209
+ });
210
+ it('should filter signals by lane', async () => {
211
+ // Arrange
212
+ process.chdir(tempDir);
213
+ await createSignal(tempDir, { message: 'CLI signal', lane: TEST_LANE });
214
+ await createSignal(tempDir, { message: 'Other lane', lane: 'Framework: Core' });
215
+ // Act
216
+ const signals = await loadSignals(tempDir, { lane: TEST_LANE });
217
+ // Assert
218
+ expect(signals).toHaveLength(1);
219
+ expect(signals[0].lane).toBe(TEST_LANE);
220
+ });
221
+ it('should filter unread signals only', async () => {
222
+ // Arrange
223
+ process.chdir(tempDir);
224
+ const result1 = await createSignal(tempDir, { message: 'Unread signal' });
225
+ await createSignal(tempDir, { message: 'Another unread' });
226
+ // Mark first as read
227
+ await markSignalsAsRead(tempDir, [result1.signal.id]);
228
+ // Act
229
+ const signals = await loadSignals(tempDir, { unreadOnly: true });
230
+ // Assert
231
+ expect(signals).toHaveLength(1);
232
+ expect(signals[0].message).toBe('Another unread');
233
+ });
234
+ it('should filter signals since a specific time', async () => {
235
+ // Arrange
236
+ process.chdir(tempDir);
237
+ const beforeTime = new Date();
238
+ // Wait a bit to ensure time difference
239
+ await new Promise((resolve) => setTimeout(resolve, 50));
240
+ await createSignal(tempDir, { message: 'Recent signal' });
241
+ // Act
242
+ const signals = await loadSignals(tempDir, { since: beforeTime });
243
+ // Assert
244
+ expect(signals).toHaveLength(1);
245
+ expect(signals[0].message).toBe('Recent signal');
246
+ });
247
+ it('should return empty array when no signals exist', async () => {
248
+ // Arrange
249
+ process.chdir(tempDir);
250
+ // Act
251
+ const signals = await loadSignals(tempDir);
252
+ // Assert
253
+ expect(signals).toHaveLength(0);
254
+ });
255
+ });
256
+ describe('mark signals as read', () => {
257
+ it('should mark signals as read', async () => {
258
+ // Arrange
259
+ process.chdir(tempDir);
260
+ const result1 = await createSignal(tempDir, { message: 'Signal 1' });
261
+ const result2 = await createSignal(tempDir, { message: 'Signal 2' });
262
+ // Act
263
+ const markResult = await markSignalsAsRead(tempDir, [result1.signal.id, result2.signal.id]);
264
+ // Assert
265
+ expect(markResult.markedCount).toBe(2);
266
+ // Verify signals are now read
267
+ const allSignals = await loadSignals(tempDir);
268
+ expect(allSignals.every((sig) => sig.read)).toBe(true);
269
+ });
270
+ it('should not count already-read signals', async () => {
271
+ // Arrange
272
+ process.chdir(tempDir);
273
+ const result = await createSignal(tempDir, { message: 'Signal 1' });
274
+ // Mark as read first time
275
+ await markSignalsAsRead(tempDir, [result.signal.id]);
276
+ // Act - Try to mark again
277
+ const secondMarkResult = await markSignalsAsRead(tempDir, [result.signal.id]);
278
+ // Assert
279
+ expect(secondMarkResult.markedCount).toBe(0);
280
+ });
281
+ });
282
+ describe('complete memory workflow', () => {
283
+ it('should support full checkpoint and signal workflow', async () => {
284
+ // This test validates the complete memory workflow:
285
+ // 1. Create initial checkpoint
286
+ // 2. Send progress signals
287
+ // 3. Check inbox for signals
288
+ // 4. Mark signals as read
289
+ // 5. Create final checkpoint
290
+ // Arrange
291
+ process.chdir(tempDir);
292
+ // Step 1: Initial checkpoint
293
+ const initialCheckpoint = await createCheckpoint(tempDir, {
294
+ note: 'Starting work on WU',
295
+ wuId: TEST_WU_ID,
296
+ sessionId: TEST_SESSION_ID,
297
+ });
298
+ expect(initialCheckpoint.success).toBe(true);
299
+ // Step 2: Send progress signals
300
+ await createSignal(tempDir, {
301
+ message: 'AC1 complete',
302
+ wuId: TEST_WU_ID,
303
+ lane: TEST_LANE,
304
+ });
305
+ await createSignal(tempDir, {
306
+ message: 'AC2 in progress',
307
+ wuId: TEST_WU_ID,
308
+ lane: TEST_LANE,
309
+ });
310
+ // Step 3: Check inbox
311
+ const inbox = await loadSignals(tempDir, { wuId: TEST_WU_ID, unreadOnly: true });
312
+ expect(inbox).toHaveLength(2);
313
+ // Step 4: Mark as read
314
+ const signalIds = inbox.map((sig) => sig.id);
315
+ await markSignalsAsRead(tempDir, signalIds);
316
+ const unreadAfter = await loadSignals(tempDir, { unreadOnly: true });
317
+ expect(unreadAfter).toHaveLength(0);
318
+ // Step 5: Final checkpoint
319
+ const finalCheckpoint = await createCheckpoint(tempDir, {
320
+ note: 'Work complete, ready for wu:done',
321
+ wuId: TEST_WU_ID,
322
+ sessionId: TEST_SESSION_ID,
323
+ progress: 'All acceptance criteria met',
324
+ nextSteps: 'Run pnpm wu:done --id ' + TEST_WU_ID,
325
+ });
326
+ expect(finalCheckpoint.success).toBe(true);
327
+ // Verify memory store has all data (memory store uses memory.jsonl)
328
+ const memoryFile = join(tempDir, '.lumenflow/memory/memory.jsonl');
329
+ expect(existsSync(memoryFile)).toBe(true);
330
+ });
331
+ });
332
+ });
333
+ });
@@ -107,7 +107,7 @@ describe('release command', () => {
107
107
  await updatePackageVersions([packagePath], '1.2.3');
108
108
  // Read back raw content and check formatting
109
109
  const content = await import('node:fs/promises').then((fs) => fs.readFile(packagePath, 'utf-8'));
110
- expect(content).toMatch(/{\n "name"/); // Preserve 2-space indent
110
+ expect(content).toMatch(/{\n {2}"name"/); // Preserve 2-space indent
111
111
  });
112
112
  });
113
113
  describe('buildCommitMessage', () => {
@@ -1,4 +1,3 @@
1
- /* eslint-disable sonarjs/no-os-command-from-path -- Test file needs to execute git commands */
2
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
2
  import { execFileSync } from 'node:child_process';
4
3
  import path from 'node:path';
@@ -229,6 +229,60 @@ describe('state-doctor CLI (WU-1230)', () => {
229
229
  expect(mockWithMicroWorktree).toHaveBeenCalled();
230
230
  });
231
231
  });
232
+ // WU-1362: Retry logic for push failures
233
+ describe('WU-1362: retry logic for push failures', () => {
234
+ it('should retry on push failure with exponential backoff', async () => {
235
+ setupTestState(testDir, {
236
+ wus: [],
237
+ events: [{ wuId: 'WU-999', type: 'claimed', timestamp: new Date().toISOString() }],
238
+ });
239
+ let callCount = 0;
240
+ const mockWithMicroWorktree = vi.mocked(withMicroWorktree);
241
+ mockWithMicroWorktree.mockImplementation(async (options) => {
242
+ callCount++;
243
+ // Simulate the micro-worktree handling retries internally
244
+ const result = await options.execute({
245
+ worktreePath: testDir,
246
+ gitWorktree: {
247
+ add: vi.fn(),
248
+ addWithDeletions: vi.fn(),
249
+ commit: vi.fn(),
250
+ push: vi.fn(),
251
+ },
252
+ });
253
+ return { ...result, ref: 'main' };
254
+ });
255
+ const { createStateDoctorFixDeps } = await import('../state-doctor-fix.js');
256
+ const deps = createStateDoctorFixDeps(testDir);
257
+ // removeEvent should succeed (micro-worktree handles retry internally)
258
+ await deps.removeEvent('WU-999');
259
+ expect(callCount).toBe(1);
260
+ });
261
+ it('should use maxRetries configuration from config', async () => {
262
+ setupTestState(testDir, {
263
+ wus: [],
264
+ events: [{ wuId: 'WU-999', type: 'claimed', timestamp: new Date().toISOString() }],
265
+ });
266
+ const mockWithMicroWorktree = vi.mocked(withMicroWorktree);
267
+ mockWithMicroWorktree.mockImplementation(async (options) => {
268
+ // Verify retries is set (micro-worktree handles retry logic)
269
+ const result = await options.execute({
270
+ worktreePath: testDir,
271
+ gitWorktree: {
272
+ add: vi.fn(),
273
+ addWithDeletions: vi.fn(),
274
+ commit: vi.fn(),
275
+ push: vi.fn(),
276
+ },
277
+ });
278
+ return { ...result, ref: 'main' };
279
+ });
280
+ const { createStateDoctorFixDeps } = await import('../state-doctor-fix.js');
281
+ const deps = createStateDoctorFixDeps(testDir);
282
+ await deps.removeEvent('WU-999');
283
+ expect(mockWithMicroWorktree).toHaveBeenCalled();
284
+ });
285
+ });
232
286
  });
233
287
  function setupTestState(baseDir, state) {
234
288
  // Create directories
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Tests for sync:templates command (WU-1368)
3
+ *
4
+ * Two bugs being fixed:
5
+ * 1. --check-drift flag syncs files instead of only checking - should be read-only
6
+ * 2. sync:templates writes directly to main checkout - should use micro-worktree isolation
7
+ *
8
+ * TDD: These tests are written BEFORE the implementation changes.
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
11
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { tmpdir } from 'node:os';
14
+ // Mock modules before importing
15
+ const mockWithMicroWorktree = vi.fn();
16
+ vi.mock('@lumenflow/core/dist/micro-worktree.js', () => ({
17
+ withMicroWorktree: mockWithMicroWorktree,
18
+ }));
19
+ vi.mock('@lumenflow/core/dist/git-adapter.js', () => ({
20
+ getGitForCwd: vi.fn(() => ({
21
+ branch: vi.fn().mockResolvedValue({ current: 'main' }),
22
+ status: vi.fn().mockResolvedValue({ isClean: () => true }),
23
+ })),
24
+ }));
25
+ vi.mock('@lumenflow/core/dist/wu-helpers.js', () => ({
26
+ ensureOnMain: vi.fn().mockResolvedValue(undefined),
27
+ }));
28
+ describe('sync:templates --check-drift', () => {
29
+ let tempDir;
30
+ let originalCwd;
31
+ beforeEach(() => {
32
+ tempDir = join(tmpdir(), `sync-templates-test-${Date.now()}`);
33
+ mkdirSync(tempDir, { recursive: true });
34
+ originalCwd = process.cwd();
35
+ process.chdir(tempDir);
36
+ // Set up minimal project structure
37
+ const templatesDir = join(tempDir, 'packages', '@lumenflow', 'cli', 'templates', 'core');
38
+ mkdirSync(templatesDir, { recursive: true });
39
+ // Create LUMENFLOW.md source
40
+ writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nLast updated: 2025-01-01\n');
41
+ // Create matching template (no drift)
42
+ writeFileSync(join(templatesDir, 'LUMENFLOW.md.template'), '# LumenFlow\n\nLast updated: {{DATE}}\n');
43
+ });
44
+ afterEach(() => {
45
+ process.chdir(originalCwd);
46
+ if (existsSync(tempDir)) {
47
+ rmSync(tempDir, { recursive: true, force: true });
48
+ }
49
+ vi.clearAllMocks();
50
+ });
51
+ describe('checkTemplateDrift', () => {
52
+ it('should NOT write any files when checking drift', async () => {
53
+ const { checkTemplateDrift } = await import('../sync-templates.js');
54
+ // Get initial file mtimes
55
+ const sourceFile = join(tempDir, 'LUMENFLOW.md');
56
+ const templateFile = join(tempDir, 'packages', '@lumenflow', 'cli', 'templates', 'core', 'LUMENFLOW.md.template');
57
+ const sourceMtimeBefore = existsSync(sourceFile) ? readFileSync(sourceFile, 'utf-8') : null;
58
+ const templateMtimeBefore = existsSync(templateFile)
59
+ ? readFileSync(templateFile, 'utf-8')
60
+ : null;
61
+ // Run check-drift
62
+ await checkTemplateDrift(tempDir);
63
+ // Verify files were NOT modified
64
+ const sourceMtimeAfter = existsSync(sourceFile) ? readFileSync(sourceFile, 'utf-8') : null;
65
+ const templateMtimeAfter = existsSync(templateFile)
66
+ ? readFileSync(templateFile, 'utf-8')
67
+ : null;
68
+ expect(sourceMtimeAfter).toBe(sourceMtimeBefore);
69
+ expect(templateMtimeAfter).toBe(templateMtimeBefore);
70
+ });
71
+ it('should return hasDrift=false when templates match source', async () => {
72
+ const { checkTemplateDrift } = await import('../sync-templates.js');
73
+ const result = await checkTemplateDrift(tempDir);
74
+ expect(result.hasDrift).toBe(false);
75
+ expect(result.driftingFiles).toHaveLength(0);
76
+ });
77
+ it('should return hasDrift=true when templates differ from source', async () => {
78
+ const { checkTemplateDrift } = await import('../sync-templates.js');
79
+ // Create source with different content
80
+ writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow Updated\n\nNew content here\n');
81
+ const result = await checkTemplateDrift(tempDir);
82
+ expect(result.hasDrift).toBe(true);
83
+ expect(result.driftingFiles.length).toBeGreaterThan(0);
84
+ });
85
+ it('should return hasDrift=true when template file is missing', async () => {
86
+ const { checkTemplateDrift } = await import('../sync-templates.js');
87
+ // Remove template file
88
+ const templateFile = join(tempDir, 'packages', '@lumenflow', 'cli', 'templates', 'core', 'LUMENFLOW.md.template');
89
+ rmSync(templateFile);
90
+ const result = await checkTemplateDrift(tempDir);
91
+ expect(result.hasDrift).toBe(true);
92
+ expect(result.driftingFiles).toContain('packages/@lumenflow/cli/templates/core/LUMENFLOW.md.template');
93
+ });
94
+ it('should NOT call withMicroWorktree during drift check', async () => {
95
+ const { checkTemplateDrift } = await import('../sync-templates.js');
96
+ await checkTemplateDrift(tempDir);
97
+ // withMicroWorktree should NOT be called for read-only drift check
98
+ expect(mockWithMicroWorktree).not.toHaveBeenCalled();
99
+ });
100
+ });
101
+ describe('exit codes', () => {
102
+ it('should exit 1 when drift is detected', async () => {
103
+ const { checkTemplateDrift } = await import('../sync-templates.js');
104
+ // Create drifting source
105
+ writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow Updated\n\nDifferent content\n');
106
+ const result = await checkTemplateDrift(tempDir);
107
+ // The result should indicate drift - CLI will use this to set exit code
108
+ expect(result.hasDrift).toBe(true);
109
+ });
110
+ it('should exit 0 when no drift detected', async () => {
111
+ const { checkTemplateDrift } = await import('../sync-templates.js');
112
+ const result = await checkTemplateDrift(tempDir);
113
+ // The result should indicate no drift - CLI will use this to set exit code
114
+ expect(result.hasDrift).toBe(false);
115
+ });
116
+ });
117
+ });
118
+ describe('sync:templates (sync mode)', () => {
119
+ let tempDir;
120
+ let originalCwd;
121
+ beforeEach(() => {
122
+ tempDir = join(tmpdir(), `sync-templates-test-${Date.now()}`);
123
+ mkdirSync(tempDir, { recursive: true });
124
+ originalCwd = process.cwd();
125
+ process.chdir(tempDir);
126
+ // Reset mock
127
+ mockWithMicroWorktree.mockReset();
128
+ mockWithMicroWorktree.mockImplementation(async ({ execute }) => {
129
+ // Simulate micro-worktree by creating temp dir and calling execute
130
+ const wtPath = join(tmpdir(), `micro-wt-${Date.now()}`);
131
+ mkdirSync(wtPath, { recursive: true });
132
+ const result = await execute({
133
+ worktreePath: wtPath,
134
+ gitWorktree: {
135
+ add: vi.fn().mockResolvedValue(undefined),
136
+ commit: vi.fn().mockResolvedValue(undefined),
137
+ },
138
+ });
139
+ return { ...result, ref: 'main' };
140
+ });
141
+ });
142
+ afterEach(() => {
143
+ process.chdir(originalCwd);
144
+ if (existsSync(tempDir)) {
145
+ rmSync(tempDir, { recursive: true, force: true });
146
+ }
147
+ vi.clearAllMocks();
148
+ });
149
+ describe('micro-worktree isolation', () => {
150
+ it('should use withMicroWorktree for sync operations', async () => {
151
+ const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
152
+ // Set up source files
153
+ writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nContent\n');
154
+ mkdirSync(join(tempDir, '.lumenflow'), { recursive: true });
155
+ writeFileSync(join(tempDir, '.lumenflow', 'constraints.md'), '# Constraints\n');
156
+ await syncTemplatesWithWorktree(tempDir);
157
+ // Verify withMicroWorktree was called
158
+ expect(mockWithMicroWorktree).toHaveBeenCalledTimes(1);
159
+ expect(mockWithMicroWorktree).toHaveBeenCalledWith(expect.objectContaining({
160
+ operation: 'sync-templates',
161
+ id: expect.any(String),
162
+ execute: expect.any(Function),
163
+ }));
164
+ });
165
+ it('should write files to micro-worktree path, not main checkout', async () => {
166
+ const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
167
+ // Set up source files
168
+ writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nContent\n');
169
+ let capturedWorktreePath = null;
170
+ mockWithMicroWorktree.mockImplementation(async ({ execute }) => {
171
+ const wtPath = join(tmpdir(), `micro-wt-verify-${Date.now()}`);
172
+ mkdirSync(wtPath, { recursive: true });
173
+ capturedWorktreePath = wtPath;
174
+ // Create templates structure in worktree
175
+ const templatesDir = join(wtPath, 'packages', '@lumenflow', 'cli', 'templates', 'core');
176
+ mkdirSync(templatesDir, { recursive: true });
177
+ const result = await execute({
178
+ worktreePath: wtPath,
179
+ gitWorktree: {
180
+ add: vi.fn().mockResolvedValue(undefined),
181
+ commit: vi.fn().mockResolvedValue(undefined),
182
+ },
183
+ });
184
+ return { ...result, ref: 'main' };
185
+ });
186
+ await syncTemplatesWithWorktree(tempDir);
187
+ // Verify worktree path was used (not main checkout)
188
+ expect(capturedWorktreePath).not.toBeNull();
189
+ expect(capturedWorktreePath).not.toBe(tempDir);
190
+ expect(capturedWorktreePath.startsWith(tmpdir())).toBe(true);
191
+ });
192
+ it('should return list of synced files for commit', async () => {
193
+ const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
194
+ // Set up source files
195
+ writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nContent\n');
196
+ let capturedResult = null;
197
+ mockWithMicroWorktree.mockImplementation(async ({ execute }) => {
198
+ const wtPath = join(tmpdir(), `micro-wt-files-${Date.now()}`);
199
+ mkdirSync(wtPath, { recursive: true });
200
+ // Create templates structure
201
+ const templatesDir = join(wtPath, 'packages', '@lumenflow', 'cli', 'templates', 'core');
202
+ mkdirSync(templatesDir, { recursive: true });
203
+ const result = await execute({
204
+ worktreePath: wtPath,
205
+ gitWorktree: {
206
+ add: vi.fn().mockResolvedValue(undefined),
207
+ commit: vi.fn().mockResolvedValue(undefined),
208
+ },
209
+ });
210
+ capturedResult = result;
211
+ return { ...result, ref: 'main' };
212
+ });
213
+ await syncTemplatesWithWorktree(tempDir);
214
+ expect(capturedResult).not.toBeNull();
215
+ expect(capturedResult.commitMessage).toContain('sync:templates');
216
+ expect(Array.isArray(capturedResult.files)).toBe(true);
217
+ });
218
+ });
219
+ describe('atomic commit', () => {
220
+ it('should create atomic commit via micro-worktree pattern', async () => {
221
+ const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
222
+ // Set up source files
223
+ writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n\nContent\n');
224
+ await syncTemplatesWithWorktree(tempDir);
225
+ // Verify withMicroWorktree was called (atomic commit pattern)
226
+ expect(mockWithMicroWorktree).toHaveBeenCalled();
227
+ // Verify the execute function returns proper commit info
228
+ const callArgs = mockWithMicroWorktree.mock.calls[0][0];
229
+ expect(callArgs.operation).toBe('sync-templates');
230
+ });
231
+ it('should include timestamp in operation id for uniqueness', async () => {
232
+ const { syncTemplatesWithWorktree } = await import('../sync-templates.js');
233
+ writeFileSync(join(tempDir, 'LUMENFLOW.md'), '# LumenFlow\n');
234
+ await syncTemplatesWithWorktree(tempDir);
235
+ const callArgs = mockWithMicroWorktree.mock.calls[0][0];
236
+ // ID should be timestamp-based or unique identifier
237
+ expect(typeof callArgs.id).toBe('string');
238
+ expect(callArgs.id.length).toBeGreaterThan(0);
239
+ });
240
+ });
241
+ });
242
+ describe('sync:templates exports', () => {
243
+ it('should export checkTemplateDrift function', async () => {
244
+ const syncTemplates = await import('../sync-templates.js');
245
+ expect(typeof syncTemplates.checkTemplateDrift).toBe('function');
246
+ });
247
+ it('should export syncTemplatesWithWorktree function', async () => {
248
+ const syncTemplates = await import('../sync-templates.js');
249
+ expect(typeof syncTemplates.syncTemplatesWithWorktree).toBe('function');
250
+ });
251
+ it('should export main function for CLI entry', async () => {
252
+ const syncTemplates = await import('../sync-templates.js');
253
+ expect(typeof syncTemplates.main).toBe('function');
254
+ });
255
+ });