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.
- package/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/CLAUDE.md +84 -201
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +1 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js +431 -0
- package/dashboard-web/dist/assets/index-W36XHPC5.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/index.js +29 -4
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/memory-api.js +180 -0
- package/server/lib/memory-api.test.js +322 -0
- package/server/lib/memory-hooks-capture.test.js +350 -0
- package/server/lib/memory-hooks.js +101 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/plan-parser.js +33 -7
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/project-scanner.js +267 -0
- package/server/lib/project-scanner.test.js +389 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +96 -0
- package/server/lib/remember-command.test.js +265 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +446 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/workspace-api.js +811 -0
- package/server/lib/workspace-api.test.js +743 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +552 -0
- package/server/package.json +4 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- 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
|
+
});
|