tlc-claude-code 1.8.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.claude/commands/tlc/bootstrap.md +77 -0
  2. package/.claude/commands/tlc/build.md +20 -6
  3. package/.claude/commands/tlc/recall.md +87 -0
  4. package/.claude/commands/tlc/remember.md +71 -0
  5. package/CLAUDE.md +84 -201
  6. package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
  7. package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
  8. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
  9. package/dashboard-web/dist/index.html +2 -2
  10. package/package.json +1 -1
  11. package/server/index.js +29 -4
  12. package/server/lib/bug-writer.js +204 -0
  13. package/server/lib/bug-writer.test.js +279 -0
  14. package/server/lib/claude-cascade.js +247 -0
  15. package/server/lib/claude-cascade.test.js +245 -0
  16. package/server/lib/context-injection.js +121 -0
  17. package/server/lib/context-injection.test.js +340 -0
  18. package/server/lib/conversation-chunker.js +320 -0
  19. package/server/lib/conversation-chunker.test.js +573 -0
  20. package/server/lib/embedding-client.js +160 -0
  21. package/server/lib/embedding-client.test.js +243 -0
  22. package/server/lib/global-config.js +198 -0
  23. package/server/lib/global-config.test.js +288 -0
  24. package/server/lib/inherited-search.js +184 -0
  25. package/server/lib/inherited-search.test.js +343 -0
  26. package/server/lib/memory-api.js +180 -0
  27. package/server/lib/memory-api.test.js +322 -0
  28. package/server/lib/memory-hooks-capture.test.js +350 -0
  29. package/server/lib/memory-hooks.js +101 -0
  30. package/server/lib/memory-inheritance.js +179 -0
  31. package/server/lib/memory-inheritance.test.js +360 -0
  32. package/server/lib/plan-parser.js +33 -7
  33. package/server/lib/plan-writer.js +196 -0
  34. package/server/lib/plan-writer.test.js +298 -0
  35. package/server/lib/project-scanner.js +267 -0
  36. package/server/lib/project-scanner.test.js +389 -0
  37. package/server/lib/project-status.js +302 -0
  38. package/server/lib/project-status.test.js +470 -0
  39. package/server/lib/projects-registry.js +237 -0
  40. package/server/lib/projects-registry.test.js +275 -0
  41. package/server/lib/recall-command.js +207 -0
  42. package/server/lib/recall-command.test.js +306 -0
  43. package/server/lib/remember-command.js +96 -0
  44. package/server/lib/remember-command.test.js +265 -0
  45. package/server/lib/rich-capture.js +221 -0
  46. package/server/lib/rich-capture.test.js +312 -0
  47. package/server/lib/roadmap-api.js +200 -0
  48. package/server/lib/roadmap-api.test.js +318 -0
  49. package/server/lib/semantic-recall.js +242 -0
  50. package/server/lib/semantic-recall.test.js +446 -0
  51. package/server/lib/setup-generator.js +315 -0
  52. package/server/lib/setup-generator.test.js +303 -0
  53. package/server/lib/test-inventory.js +112 -0
  54. package/server/lib/test-inventory.test.js +360 -0
  55. package/server/lib/vector-indexer.js +246 -0
  56. package/server/lib/vector-indexer.test.js +459 -0
  57. package/server/lib/vector-store.js +260 -0
  58. package/server/lib/vector-store.test.js +706 -0
  59. package/server/lib/workspace-api.js +811 -0
  60. package/server/lib/workspace-api.test.js +743 -0
  61. package/server/lib/workspace-bootstrap.js +164 -0
  62. package/server/lib/workspace-bootstrap.test.js +503 -0
  63. package/server/lib/workspace-context.js +129 -0
  64. package/server/lib/workspace-context.test.js +214 -0
  65. package/server/lib/workspace-detector.js +162 -0
  66. package/server/lib/workspace-detector.test.js +193 -0
  67. package/server/lib/workspace-init.js +307 -0
  68. package/server/lib/workspace-init.test.js +244 -0
  69. package/server/lib/workspace-snapshot.js +236 -0
  70. package/server/lib/workspace-snapshot.test.js +444 -0
  71. package/server/lib/workspace-watcher.js +162 -0
  72. package/server/lib/workspace-watcher.test.js +257 -0
  73. package/server/package-lock.json +552 -0
  74. package/server/package.json +4 -0
  75. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  76. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  77. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Workspace Snapshot & Restore
3
+ * Capture workspace state (branches, uncommitted changes, TLC phase) and restore it.
4
+ * "Where was I?" across machines.
5
+ */
6
+
7
+ import { execSync } from 'child_process';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+
11
+ /**
12
+ * Get git state for a single repo
13
+ * @param {string} repoPath - Absolute path to the repository
14
+ * @returns {Object} Git state with branch, lastCommit, hasUncommitted
15
+ */
16
+ function getGitState(repoPath) {
17
+ let branch = null;
18
+ let lastCommit = null;
19
+ let hasUncommitted = false;
20
+
21
+ try {
22
+ branch = String(execSync('git rev-parse --abbrev-ref HEAD', {
23
+ cwd: repoPath,
24
+ encoding: 'utf-8',
25
+ })).trim();
26
+ } catch {
27
+ branch = null;
28
+ }
29
+
30
+ try {
31
+ lastCommit = String(execSync('git rev-parse HEAD', {
32
+ cwd: repoPath,
33
+ encoding: 'utf-8',
34
+ })).trim();
35
+ } catch {
36
+ lastCommit = null;
37
+ }
38
+
39
+ try {
40
+ const status = String(execSync('git status --porcelain', {
41
+ cwd: repoPath,
42
+ encoding: 'utf-8',
43
+ })).trim();
44
+ hasUncommitted = status.length > 0;
45
+ } catch {
46
+ hasUncommitted = false;
47
+ }
48
+
49
+ return { branch, lastCommit, hasUncommitted };
50
+ }
51
+
52
+ /**
53
+ * Detect current TLC phase from ROADMAP.md
54
+ * Looks for a line with [>] marker indicating the active phase.
55
+ * @param {string} repoPath - Absolute path to the repository
56
+ * @returns {{ phase: number|null, phaseName: string|null }}
57
+ */
58
+ function detectTlcPhase(repoPath) {
59
+ const roadmapPath = path.join(repoPath, '.planning', 'ROADMAP.md');
60
+
61
+ try {
62
+ if (!fs.existsSync(roadmapPath)) {
63
+ return { phase: null, phaseName: null };
64
+ }
65
+
66
+ const content = fs.readFileSync(roadmapPath, 'utf-8');
67
+ const lines = content.split('\n');
68
+
69
+ for (const line of lines) {
70
+ // Match lines like: - [>] Phase 2: Core Features
71
+ const match = line.match(/\[>\]\s*Phase\s+(\d+):\s*(.+)/i);
72
+ if (match) {
73
+ return {
74
+ phase: parseInt(match[1], 10),
75
+ phaseName: match[2].trim(),
76
+ };
77
+ }
78
+ }
79
+
80
+ return { phase: null, phaseName: null };
81
+ } catch {
82
+ return { phase: null, phaseName: null };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Detect active tasks from the current phase PLAN.md
88
+ * Looks for lines with [>@assignee] markers.
89
+ * @param {string} repoPath - Absolute path to the repository
90
+ * @param {number|null} phaseNumber - Current phase number
91
+ * @returns {Array<{ task: string, assignee: string }>}
92
+ */
93
+ function detectActiveTasks(repoPath, phaseNumber) {
94
+ if (!phaseNumber) return [];
95
+
96
+ const planPath = path.join(repoPath, '.planning', 'phases', `${phaseNumber}-PLAN.md`);
97
+
98
+ try {
99
+ if (!fs.existsSync(planPath)) {
100
+ return [];
101
+ }
102
+
103
+ const content = fs.readFileSync(planPath, 'utf-8');
104
+ const lines = content.split('\n');
105
+ const activeTasks = [];
106
+
107
+ for (const line of lines) {
108
+ // Match lines like: ### Task 2: API Routes [>@bob]
109
+ const match = line.match(/###\s*Task\s+\d+:\s*(.+?)\s*\[>@(\w+)\]/i);
110
+ if (match) {
111
+ activeTasks.push({
112
+ task: match[1].trim(),
113
+ assignee: match[2].trim(),
114
+ });
115
+ }
116
+ }
117
+
118
+ return activeTasks;
119
+ } catch {
120
+ return [];
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Factory function to create a workspace snapshot manager
126
+ * @param {Object} options - Options
127
+ * @param {Object} options.registry - Registry with listProjects() method
128
+ * @returns {Object} Snapshot manager with snapshot, restore, diff methods
129
+ */
130
+ export function createWorkspaceSnapshot({ registry }) {
131
+ /**
132
+ * Capture current workspace state for all registered projects
133
+ * @param {string} workspaceRoot - Absolute path to the workspace root
134
+ * @returns {Object} State object with timestamp and per-project state
135
+ */
136
+ async function snapshot(workspaceRoot) {
137
+ const projects = await registry.listProjects();
138
+ const projectStates = [];
139
+
140
+ for (const project of projects) {
141
+ const repoPath = path.join(workspaceRoot, project.localPath);
142
+ const gitState = getGitState(repoPath);
143
+ const { phase, phaseName } = detectTlcPhase(repoPath);
144
+ const activeTasks = detectActiveTasks(repoPath, phase);
145
+
146
+ projectStates.push({
147
+ name: project.name,
148
+ branch: gitState.branch,
149
+ lastCommit: gitState.lastCommit,
150
+ hasUncommitted: gitState.hasUncommitted,
151
+ tlcPhase: phase,
152
+ tlcPhaseName: phaseName,
153
+ activeTasks,
154
+ });
155
+ }
156
+
157
+ const state = {
158
+ timestamp: Date.now(),
159
+ projects: projectStates,
160
+ };
161
+
162
+ // Save to workspace-state.json
163
+ const stateFile = path.join(workspaceRoot, 'workspace-state.json');
164
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2), 'utf-8');
165
+
166
+ return state;
167
+ }
168
+
169
+ /**
170
+ * Restore workspace state from workspace-state.json
171
+ * Checks out the saved branch for each project.
172
+ * @param {string} workspaceRoot - Absolute path to the workspace root
173
+ * @returns {Object} The restored state
174
+ */
175
+ async function restore(workspaceRoot) {
176
+ const stateFile = path.join(workspaceRoot, 'workspace-state.json');
177
+ const content = fs.readFileSync(stateFile, 'utf-8');
178
+ const state = JSON.parse(content);
179
+
180
+ for (const project of state.projects) {
181
+ if (!project.branch) continue;
182
+
183
+ const repoPath = path.join(workspaceRoot, project.name);
184
+ execSync(`git checkout ${project.branch}`, {
185
+ cwd: repoPath,
186
+ encoding: 'utf-8',
187
+ });
188
+ }
189
+
190
+ return state;
191
+ }
192
+
193
+ /**
194
+ * Compare current workspace state to saved snapshot
195
+ * @param {string} workspaceRoot - Absolute path to the workspace root
196
+ * @returns {Array<{ project: string, field: string, was: any, now: any }>} Changes
197
+ */
198
+ async function diff(workspaceRoot) {
199
+ // Read saved state
200
+ const stateFile = path.join(workspaceRoot, 'workspace-state.json');
201
+ const content = fs.readFileSync(stateFile, 'utf-8');
202
+ const savedState = JSON.parse(content);
203
+
204
+ // Get current state for each project
205
+ const projects = await registry.listProjects();
206
+ const changes = [];
207
+
208
+ for (const project of projects) {
209
+ const repoPath = path.join(workspaceRoot, project.localPath);
210
+ const currentGit = getGitState(repoPath);
211
+
212
+ const savedProject = savedState.projects.find(p => p.name === project.name);
213
+ if (!savedProject) continue;
214
+
215
+ // Compare fields
216
+ const fieldsToCompare = ['branch', 'lastCommit', 'hasUncommitted'];
217
+ for (const field of fieldsToCompare) {
218
+ const savedValue = savedProject[field];
219
+ const currentValue = currentGit[field];
220
+
221
+ if (savedValue !== currentValue) {
222
+ changes.push({
223
+ project: project.name,
224
+ field,
225
+ was: savedValue,
226
+ now: currentValue,
227
+ });
228
+ }
229
+ }
230
+ }
231
+
232
+ return changes;
233
+ }
234
+
235
+ return { snapshot, restore, diff };
236
+ }
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Workspace Snapshot & Restore Tests
3
+ * Capture workspace state (branches, uncommitted changes, TLC phase) and restore it.
4
+ * "Where was I?" across machines.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import os from 'os';
11
+
12
+ // Mock child_process before importing the module under test
13
+ vi.mock('child_process', () => ({
14
+ execSync: vi.fn(),
15
+ }));
16
+
17
+ const { execSync } = await import('child_process');
18
+ const { createWorkspaceSnapshot } = await import('./workspace-snapshot.js');
19
+
20
+ describe('WorkspaceSnapshot', () => {
21
+ let tempDir;
22
+ let registry;
23
+ let snap;
24
+
25
+ /**
26
+ * Helper: create a fake sub-repo directory structure
27
+ */
28
+ function createSubRepo(name, options = {}) {
29
+ const repoPath = path.join(tempDir, name);
30
+ fs.mkdirSync(repoPath, { recursive: true });
31
+ fs.mkdirSync(path.join(repoPath, '.git'), { recursive: true });
32
+
33
+ if (options.roadmap) {
34
+ const planningDir = path.join(repoPath, '.planning');
35
+ fs.mkdirSync(path.join(planningDir, 'phases'), { recursive: true });
36
+ fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), options.roadmap);
37
+ }
38
+
39
+ if (options.planFile) {
40
+ const planningDir = path.join(repoPath, '.planning', 'phases');
41
+ fs.mkdirSync(planningDir, { recursive: true });
42
+ fs.writeFileSync(
43
+ path.join(planningDir, options.planFile.name),
44
+ options.planFile.content
45
+ );
46
+ }
47
+
48
+ return repoPath;
49
+ }
50
+
51
+ /**
52
+ * Helper: configure execSync mock responses for a given repo
53
+ * @param {string} repoPath - Absolute path to the repo
54
+ * @param {Object} gitState - Git state to mock
55
+ */
56
+ function mockGitState(repoPath, gitState = {}) {
57
+ const {
58
+ branch = 'main',
59
+ lastCommit = 'abc1234',
60
+ hasUncommitted = false,
61
+ detachedHead = false,
62
+ noCommits = false,
63
+ } = gitState;
64
+
65
+ execSync.mockImplementation((cmd, opts) => {
66
+ const cwd = opts?.cwd || '';
67
+
68
+ if (!cwd.startsWith(repoPath) && cwd !== repoPath) {
69
+ // Let other repos fall through to a default or throw
70
+ throw new Error(`Unexpected cwd: ${cwd}`);
71
+ }
72
+
73
+ if (cmd.includes('rev-parse --abbrev-ref HEAD')) {
74
+ if (noCommits) {
75
+ throw new Error('fatal: ambiguous argument HEAD');
76
+ }
77
+ return detachedHead ? 'HEAD' : branch;
78
+ }
79
+
80
+ if (cmd.includes('rev-parse HEAD')) {
81
+ if (noCommits) {
82
+ throw new Error('fatal: ambiguous argument HEAD');
83
+ }
84
+ return lastCommit;
85
+ }
86
+
87
+ if (cmd.includes('status --porcelain')) {
88
+ return hasUncommitted ? ' M src/index.js\n?? new-file.js\n' : '';
89
+ }
90
+
91
+ if (cmd.includes('checkout')) {
92
+ return '';
93
+ }
94
+
95
+ throw new Error(`Unmocked git command: ${cmd}`);
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Helper: configure execSync to handle multiple repos
101
+ * @param {Object} repoStates - Map of repoPath -> gitState
102
+ */
103
+ function mockMultiRepoGitState(repoStates) {
104
+ execSync.mockImplementation((cmd, opts) => {
105
+ const cwd = opts?.cwd || '';
106
+
107
+ for (const [repoPath, gitState] of Object.entries(repoStates)) {
108
+ if (cwd === repoPath || cwd.startsWith(repoPath + path.sep)) {
109
+ const {
110
+ branch = 'main',
111
+ lastCommit = 'abc1234',
112
+ hasUncommitted = false,
113
+ detachedHead = false,
114
+ noCommits = false,
115
+ } = gitState;
116
+
117
+ if (cmd.includes('rev-parse --abbrev-ref HEAD')) {
118
+ if (noCommits) throw new Error('fatal: ambiguous argument HEAD');
119
+ return detachedHead ? 'HEAD' : branch;
120
+ }
121
+
122
+ if (cmd.includes('rev-parse HEAD')) {
123
+ if (noCommits) throw new Error('fatal: ambiguous argument HEAD');
124
+ return lastCommit;
125
+ }
126
+
127
+ if (cmd.includes('status --porcelain')) {
128
+ return hasUncommitted ? ' M src/index.js\n' : '';
129
+ }
130
+
131
+ if (cmd.includes('checkout')) {
132
+ return '';
133
+ }
134
+
135
+ throw new Error(`Unmocked git command: ${cmd}`);
136
+ }
137
+ }
138
+
139
+ throw new Error(`No mock configured for cwd: ${cwd}, cmd: ${cmd}`);
140
+ });
141
+ }
142
+
143
+ beforeEach(() => {
144
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-snapshot-test-'));
145
+ vi.clearAllMocks();
146
+
147
+ // Default registry mock returning two projects
148
+ registry = {
149
+ listProjects: vi.fn().mockResolvedValue([
150
+ { name: 'api', localPath: 'api', defaultBranch: 'main' },
151
+ { name: 'web', localPath: 'web', defaultBranch: 'main' },
152
+ ]),
153
+ };
154
+
155
+ snap = createWorkspaceSnapshot({ registry });
156
+ });
157
+
158
+ afterEach(() => {
159
+ fs.rmSync(tempDir, { recursive: true, force: true });
160
+ });
161
+
162
+ describe('snapshot', () => {
163
+ it('captures branch and last commit per repo', async () => {
164
+ const apiPath = createSubRepo('api');
165
+ const webPath = createSubRepo('web');
166
+
167
+ mockMultiRepoGitState({
168
+ [apiPath]: { branch: 'main', lastCommit: 'aaa1111' },
169
+ [webPath]: { branch: 'develop', lastCommit: 'bbb2222' },
170
+ });
171
+
172
+ const state = await snap.snapshot(tempDir);
173
+
174
+ expect(state.projects).toHaveLength(2);
175
+
176
+ const api = state.projects.find(p => p.name === 'api');
177
+ expect(api.branch).toBe('main');
178
+ expect(api.lastCommit).toBe('aaa1111');
179
+
180
+ const web = state.projects.find(p => p.name === 'web');
181
+ expect(web.branch).toBe('develop');
182
+ expect(web.lastCommit).toBe('bbb2222');
183
+ });
184
+
185
+ it('captures uncommitted changes indicator (hasUncommitted boolean)', async () => {
186
+ const apiPath = createSubRepo('api');
187
+ const webPath = createSubRepo('web');
188
+
189
+ mockMultiRepoGitState({
190
+ [apiPath]: { hasUncommitted: true },
191
+ [webPath]: { hasUncommitted: false },
192
+ });
193
+
194
+ const state = await snap.snapshot(tempDir);
195
+
196
+ const api = state.projects.find(p => p.name === 'api');
197
+ expect(api.hasUncommitted).toBe(true);
198
+
199
+ const web = state.projects.find(p => p.name === 'web');
200
+ expect(web.hasUncommitted).toBe(false);
201
+ });
202
+
203
+ it('captures TLC phase per repo (reads ROADMAP.md for current phase marker)', async () => {
204
+ const roadmapContent = [
205
+ '# Roadmap',
206
+ '',
207
+ '## Phases',
208
+ '',
209
+ '- [x] Phase 1: Setup',
210
+ '- [>] Phase 2: Core Features',
211
+ '- [ ] Phase 3: Polish',
212
+ ].join('\n');
213
+
214
+ const apiPath = createSubRepo('api', { roadmap: roadmapContent });
215
+ const webPath = createSubRepo('web');
216
+
217
+ mockMultiRepoGitState({
218
+ [apiPath]: {},
219
+ [webPath]: {},
220
+ });
221
+
222
+ const state = await snap.snapshot(tempDir);
223
+
224
+ const api = state.projects.find(p => p.name === 'api');
225
+ expect(api.tlcPhase).toBe(2);
226
+ expect(api.tlcPhaseName).toBe('Core Features');
227
+
228
+ // web has no ROADMAP.md, so phase should be null/undefined or 0
229
+ const web = state.projects.find(p => p.name === 'web');
230
+ expect(web.tlcPhase).toBeNull();
231
+ });
232
+
233
+ it('includes timestamp', async () => {
234
+ const apiPath = createSubRepo('api');
235
+ const webPath = createSubRepo('web');
236
+
237
+ mockMultiRepoGitState({
238
+ [apiPath]: {},
239
+ [webPath]: {},
240
+ });
241
+
242
+ const before = Date.now();
243
+ const state = await snap.snapshot(tempDir);
244
+ const after = Date.now();
245
+
246
+ expect(state.timestamp).toBeDefined();
247
+ expect(typeof state.timestamp).toBe('number');
248
+ expect(state.timestamp).toBeGreaterThanOrEqual(before);
249
+ expect(state.timestamp).toBeLessThanOrEqual(after);
250
+ });
251
+
252
+ it('handles repo with no commits (empty repo)', async () => {
253
+ const apiPath = createSubRepo('api');
254
+ const webPath = createSubRepo('web');
255
+
256
+ // api has no commits, web is normal
257
+ mockMultiRepoGitState({
258
+ [apiPath]: { noCommits: true },
259
+ [webPath]: { branch: 'main', lastCommit: 'ccc3333' },
260
+ });
261
+
262
+ const state = await snap.snapshot(tempDir);
263
+
264
+ const api = state.projects.find(p => p.name === 'api');
265
+ expect(api.branch).toBeNull();
266
+ expect(api.lastCommit).toBeNull();
267
+
268
+ // web should still work fine
269
+ const web = state.projects.find(p => p.name === 'web');
270
+ expect(web.branch).toBe('main');
271
+ expect(web.lastCommit).toBe('ccc3333');
272
+ });
273
+
274
+ it('handles repo on detached HEAD', async () => {
275
+ const apiPath = createSubRepo('api');
276
+ const webPath = createSubRepo('web');
277
+
278
+ mockMultiRepoGitState({
279
+ [apiPath]: { detachedHead: true, lastCommit: 'ddd4444' },
280
+ [webPath]: {},
281
+ });
282
+
283
+ const state = await snap.snapshot(tempDir);
284
+
285
+ const api = state.projects.find(p => p.name === 'api');
286
+ expect(api.branch).toBe('HEAD');
287
+ expect(api.lastCommit).toBe('ddd4444');
288
+ });
289
+
290
+ it('captures active tasks per repo (reads current PLAN.md for [>@] markers)', async () => {
291
+ const planContent = [
292
+ '# Phase 2: Core Features - Plan',
293
+ '',
294
+ '## Tasks',
295
+ '',
296
+ '### Task 1: Auth [x@alice]',
297
+ '',
298
+ '### Task 2: API Routes [>@bob]',
299
+ '',
300
+ '### Task 3: Database [>@carol]',
301
+ '',
302
+ '### Task 4: Tests [ ]',
303
+ ].join('\n');
304
+
305
+ const apiPath = createSubRepo('api', {
306
+ roadmap: '- [>] Phase 2: Core Features\n',
307
+ planFile: { name: '2-PLAN.md', content: planContent },
308
+ });
309
+ const webPath = createSubRepo('web');
310
+
311
+ mockMultiRepoGitState({
312
+ [apiPath]: {},
313
+ [webPath]: {},
314
+ });
315
+
316
+ const state = await snap.snapshot(tempDir);
317
+
318
+ const api = state.projects.find(p => p.name === 'api');
319
+ expect(api.activeTasks).toBeDefined();
320
+ expect(api.activeTasks).toHaveLength(2);
321
+ expect(api.activeTasks).toEqual(
322
+ expect.arrayContaining([
323
+ expect.objectContaining({ task: 'API Routes', assignee: 'bob' }),
324
+ expect.objectContaining({ task: 'Database', assignee: 'carol' }),
325
+ ])
326
+ );
327
+ });
328
+ });
329
+
330
+ describe('save to file', () => {
331
+ it('saves snapshot to workspace-state.json', async () => {
332
+ const apiPath = createSubRepo('api');
333
+ const webPath = createSubRepo('web');
334
+
335
+ mockMultiRepoGitState({
336
+ [apiPath]: { branch: 'main', lastCommit: 'aaa1111' },
337
+ [webPath]: { branch: 'develop', lastCommit: 'bbb2222' },
338
+ });
339
+
340
+ await snap.snapshot(tempDir);
341
+
342
+ const stateFile = path.join(tempDir, 'workspace-state.json');
343
+ expect(fs.existsSync(stateFile)).toBe(true);
344
+
345
+ const saved = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
346
+ expect(saved.timestamp).toBeDefined();
347
+ expect(saved.projects).toHaveLength(2);
348
+ expect(saved.projects.find(p => p.name === 'api').branch).toBe('main');
349
+ });
350
+ });
351
+
352
+ describe('restore', () => {
353
+ it('checks out correct branches (mock exec for git checkout)', async () => {
354
+ createSubRepo('api');
355
+ createSubRepo('web');
356
+
357
+ // Pre-create workspace-state.json with a previous snapshot
358
+ const previousState = {
359
+ timestamp: Date.now() - 60000,
360
+ projects: [
361
+ { name: 'api', branch: 'feature/auth', lastCommit: 'aaa1111', hasUncommitted: false },
362
+ { name: 'web', branch: 'develop', lastCommit: 'bbb2222', hasUncommitted: false },
363
+ ],
364
+ };
365
+ fs.writeFileSync(
366
+ path.join(tempDir, 'workspace-state.json'),
367
+ JSON.stringify(previousState, null, 2)
368
+ );
369
+
370
+ // Track checkout calls
371
+ const checkoutCalls = [];
372
+ execSync.mockImplementation((cmd, opts) => {
373
+ if (cmd.includes('checkout')) {
374
+ checkoutCalls.push({ cmd, cwd: opts?.cwd });
375
+ return '';
376
+ }
377
+ return '';
378
+ });
379
+
380
+ await snap.restore(tempDir);
381
+
382
+ // Should have checked out the correct branches
383
+ expect(checkoutCalls).toHaveLength(2);
384
+
385
+ const apiCheckout = checkoutCalls.find(c => c.cwd.includes('api'));
386
+ expect(apiCheckout).toBeDefined();
387
+ expect(apiCheckout.cmd).toContain('feature/auth');
388
+
389
+ const webCheckout = checkoutCalls.find(c => c.cwd.includes('web'));
390
+ expect(webCheckout).toBeDefined();
391
+ expect(webCheckout.cmd).toContain('develop');
392
+ });
393
+ });
394
+
395
+ describe('diff', () => {
396
+ it('shows changes since last snapshot (branch change, new commits)', async () => {
397
+ const apiPath = createSubRepo('api');
398
+ const webPath = createSubRepo('web');
399
+
400
+ // Pre-create workspace-state.json with a previous snapshot
401
+ const previousState = {
402
+ timestamp: Date.now() - 60000,
403
+ projects: [
404
+ { name: 'api', branch: 'main', lastCommit: 'aaa1111', hasUncommitted: false },
405
+ { name: 'web', branch: 'main', lastCommit: 'bbb2222', hasUncommitted: false },
406
+ ],
407
+ };
408
+ fs.writeFileSync(
409
+ path.join(tempDir, 'workspace-state.json'),
410
+ JSON.stringify(previousState, null, 2)
411
+ );
412
+
413
+ // Now the repos have changed
414
+ mockMultiRepoGitState({
415
+ [apiPath]: { branch: 'feature/auth', lastCommit: 'aaa9999' },
416
+ [webPath]: { branch: 'main', lastCommit: 'bbb2222' }, // unchanged
417
+ });
418
+
419
+ const changes = await snap.diff(tempDir);
420
+
421
+ // api changed branch and commit
422
+ expect(changes).toEqual(
423
+ expect.arrayContaining([
424
+ expect.objectContaining({
425
+ project: 'api',
426
+ field: 'branch',
427
+ was: 'main',
428
+ now: 'feature/auth',
429
+ }),
430
+ expect.objectContaining({
431
+ project: 'api',
432
+ field: 'lastCommit',
433
+ was: 'aaa1111',
434
+ now: 'aaa9999',
435
+ }),
436
+ ])
437
+ );
438
+
439
+ // web did not change - should NOT appear in changes
440
+ const webChanges = changes.filter(c => c.project === 'web');
441
+ expect(webChanges).toHaveLength(0);
442
+ });
443
+ });
444
+ });