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,470 @@
1
+ /**
2
+ * @file project-status.test.js
3
+ * @description Tests for the Project Status module (Phase 75, Task 1).
4
+ *
5
+ * Tests the factory function `createProjectStatus(deps)` which accepts injected
6
+ * dependencies (fs, execSync) and returns `{ getFullStatus(projectPath) }`.
7
+ *
8
+ * The module parses a project's `.planning/ROADMAP.md` to extract a full roadmap
9
+ * with milestones, phases (with goals, deliverables, status), reads per-phase
10
+ * PLAN.md for task counts, TESTS.md for test counts, VERIFIED.md for verification
11
+ * status, and extracts recent git commits and project info.
12
+ *
13
+ * All filesystem and git operations are injected as dependencies so tests can
14
+ * run entirely against mock data with no real filesystem access.
15
+ *
16
+ * TDD: RED phase — these tests are written BEFORE the implementation.
17
+ */
18
+ import { describe, it, beforeEach, expect, vi } from 'vitest';
19
+ import { createProjectStatus } from './project-status.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Mock factories
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Creates a mock `fs` dependency backed by an in-memory file map.
27
+ * Keys are absolute file paths, values are file contents (strings).
28
+ * @param {Record<string, string>} files - Map of path -> content
29
+ * @returns {{ existsSync: vi.fn, readFileSync: vi.fn, readdirSync: vi.fn }}
30
+ */
31
+ function createMockFs(files = {}) {
32
+ return {
33
+ existsSync: vi.fn((p) => p in files),
34
+ readFileSync: vi.fn((p) => {
35
+ if (p in files) return files[p];
36
+ throw new Error(`ENOENT: no such file or directory, open '${p}'`);
37
+ }),
38
+ readdirSync: vi.fn((p) => {
39
+ // Return filenames that are direct children of the directory path
40
+ return Object.keys(files)
41
+ .filter((f) => f.startsWith(p + '/') && !f.slice(p.length + 1).includes('/'))
42
+ .map((f) => f.slice(p.length + 1));
43
+ }),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Creates a mock `execSync` that returns a configurable string for git log.
49
+ * @param {string} output - The stdout to return
50
+ * @returns {vi.fn}
51
+ */
52
+ function createMockExecSync(output = '') {
53
+ return vi.fn(() => output);
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Shared mock content
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const MOCK_ROADMAP = `# TLC Roadmap - v1.0
61
+
62
+ ## Milestone: v1.0 - Team Collaboration Release
63
+
64
+ ### Phase 1: Core Infrastructure [x]
65
+
66
+ **Goal:** Establish TLC as source of truth for planning.
67
+
68
+ **Deliverables:**
69
+ - [x] CLAUDE.md enforcement
70
+ - [x] Multi-user task claiming
71
+
72
+ ---
73
+
74
+ ### Phase 2: Test Quality [x] ✓ COMPLETE
75
+
76
+ **Goal:** Improve test quality metrics.
77
+
78
+ **Deliverables:**
79
+ - [x] Test quality scoring
80
+ - [x] Auto-fix on failure
81
+
82
+ ---
83
+
84
+ ### Phase 3: Dev Server [>]
85
+
86
+ **Goal:** Unified development environment.
87
+
88
+ **Deliverables:**
89
+ - [x] Auto-detect project type
90
+ - [ ] Docker-compose generation
91
+
92
+ ---
93
+
94
+ ## Milestone: v2.0 - Standalone Release
95
+
96
+ ### Phase 4: LLM Router [ ]
97
+
98
+ **Goal:** Multi-model support.
99
+
100
+ **Deliverables:**
101
+ - [ ] Model routing
102
+ - [ ] Provider config
103
+ `;
104
+
105
+ const MOCK_PLAN = `# Phase 1: Core Infrastructure - Plan
106
+
107
+ ## Tasks
108
+
109
+ ### Task 1: CLAUDE.md [x]
110
+
111
+ ### Task 2: Multi-user [x]
112
+
113
+ ### Task 3: Bug tracking [ ]
114
+ `;
115
+
116
+ const MOCK_TESTS = `# Phase 1 Tests
117
+
118
+ ## Test Files
119
+
120
+ | File | Tests | Status |
121
+ |------|-------|--------|
122
+ | lib/core.test.js | 10 | Passing |
123
+ | lib/user.test.js | 5 | Passing |
124
+ | **Total** | **15** | **All Passing** |
125
+ `;
126
+
127
+ const MOCK_PACKAGE_JSON = JSON.stringify({
128
+ name: 'test-project',
129
+ version: '2.1.0',
130
+ description: 'A test project for TLC',
131
+ });
132
+
133
+ const MOCK_PROJECT_MD = `# Test Project
134
+
135
+ This is the project description paragraph that should be extracted.
136
+
137
+ ## Architecture
138
+
139
+ Some architecture notes.
140
+ `;
141
+
142
+ const MOCK_GIT_LOG = `abc1234|feat: add roadmap parser|2026-02-09|Jurgen
143
+ def5678|fix: correct milestone grouping|2026-02-08|Alice
144
+ ghi9012|test: add project-status tests|2026-02-07|Bob`;
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Tests
148
+ // ---------------------------------------------------------------------------
149
+
150
+ describe('project-status', () => {
151
+ let mockFs;
152
+ let mockExecSync;
153
+ let projectStatus;
154
+
155
+ /**
156
+ * Builds a full file map for a project at the given path.
157
+ * Includes ROADMAP.md, a Phase 1 PLAN, TESTS, and VERIFIED file,
158
+ * plus package.json and PROJECT.md.
159
+ * @param {string} projectPath - Absolute path to the project root
160
+ * @returns {Record<string, string>} File map
161
+ */
162
+ function buildFullFileMap(projectPath) {
163
+ return {
164
+ [`${projectPath}/package.json`]: MOCK_PACKAGE_JSON,
165
+ [`${projectPath}/PROJECT.md`]: MOCK_PROJECT_MD,
166
+ [`${projectPath}/.planning/ROADMAP.md`]: MOCK_ROADMAP,
167
+ [`${projectPath}/.planning/phases`]: '', // directory marker
168
+ [`${projectPath}/.planning/phases/1-PLAN.md`]: MOCK_PLAN,
169
+ [`${projectPath}/.planning/phases/1-TESTS.md`]: MOCK_TESTS,
170
+ [`${projectPath}/.planning/phases/1-VERIFIED.md`]: '# Verified\nPhase 1 verified.',
171
+ };
172
+ }
173
+
174
+ beforeEach(() => {
175
+ const files = buildFullFileMap('/project');
176
+ mockFs = createMockFs(files);
177
+ mockExecSync = createMockExecSync(MOCK_GIT_LOG);
178
+ projectStatus = createProjectStatus({ fs: mockFs, execSync: mockExecSync });
179
+ });
180
+
181
+ it('parses full roadmap with milestones grouping phases', () => {
182
+ const result = projectStatus.getFullStatus('/project');
183
+
184
+ expect(result.milestones).toBeDefined();
185
+ expect(Array.isArray(result.milestones)).toBe(true);
186
+ expect(result.milestones).toHaveLength(2);
187
+
188
+ // First milestone has 3 phases
189
+ expect(result.milestones[0].name).toBe('v1.0 - Team Collaboration Release');
190
+ expect(result.milestones[0].phases).toHaveLength(3);
191
+
192
+ // Second milestone has 1 phase
193
+ expect(result.milestones[1].name).toBe('v2.0 - Standalone Release');
194
+ expect(result.milestones[1].phases).toHaveLength(1);
195
+
196
+ // Total phases
197
+ expect(result.totalPhases).toBe(4);
198
+ });
199
+
200
+ it('extracts phase goals from ROADMAP.md', () => {
201
+ const result = projectStatus.getFullStatus('/project');
202
+
203
+ const phase1 = result.milestones[0].phases[0];
204
+ expect(phase1.goal).toBe('Establish TLC as source of truth for planning.');
205
+
206
+ const phase2 = result.milestones[0].phases[1];
207
+ expect(phase2.goal).toBe('Improve test quality metrics.');
208
+
209
+ const phase3 = result.milestones[0].phases[2];
210
+ expect(phase3.goal).toBe('Unified development environment.');
211
+
212
+ const phase4 = result.milestones[1].phases[0];
213
+ expect(phase4.goal).toBe('Multi-model support.');
214
+ });
215
+
216
+ it('extracts phase deliverables as arrays', () => {
217
+ const result = projectStatus.getFullStatus('/project');
218
+
219
+ const phase1 = result.milestones[0].phases[0];
220
+ expect(phase1.deliverables).toHaveLength(2);
221
+ expect(phase1.deliverables[0]).toEqual({ text: 'CLAUDE.md enforcement', done: true });
222
+ expect(phase1.deliverables[1]).toEqual({ text: 'Multi-user task claiming', done: true });
223
+
224
+ const phase3 = result.milestones[0].phases[2];
225
+ expect(phase3.deliverables).toHaveLength(2);
226
+ expect(phase3.deliverables[0]).toEqual({ text: 'Auto-detect project type', done: true });
227
+ expect(phase3.deliverables[1]).toEqual({ text: 'Docker-compose generation', done: false });
228
+
229
+ const phase4 = result.milestones[1].phases[0];
230
+ expect(phase4.deliverables).toHaveLength(2);
231
+ expect(phase4.deliverables[0]).toEqual({ text: 'Model routing', done: false });
232
+ expect(phase4.deliverables[1]).toEqual({ text: 'Provider config', done: false });
233
+ });
234
+
235
+ it('counts tasks from PLAN.md files', () => {
236
+ const result = projectStatus.getFullStatus('/project');
237
+
238
+ // Phase 1 has a PLAN.md with 3 tasks, 2 completed
239
+ const phase1 = result.milestones[0].phases[0];
240
+ expect(phase1.taskCount).toBe(3);
241
+ expect(phase1.completedTaskCount).toBe(2);
242
+ });
243
+
244
+ it('parses test counts from TESTS.md', () => {
245
+ const result = projectStatus.getFullStatus('/project');
246
+
247
+ // Phase 1 has a TESTS.md with 2 test files, 15 total tests
248
+ const phase1 = result.milestones[0].phases[0];
249
+ expect(phase1.hasTests).toBe(true);
250
+ expect(phase1.testFileCount).toBe(2);
251
+ expect(phase1.testCount).toBe(15);
252
+
253
+ // Test summary should aggregate
254
+ expect(result.testSummary).toBeDefined();
255
+ expect(result.testSummary.totalFiles).toBe(2);
256
+ expect(result.testSummary.totalTests).toBe(15);
257
+ });
258
+
259
+ it('detects verified phases from VERIFIED.md', () => {
260
+ const result = projectStatus.getFullStatus('/project');
261
+
262
+ // Phase 1 has a VERIFIED.md
263
+ const phase1 = result.milestones[0].phases[0];
264
+ expect(phase1.verified).toBe(true);
265
+
266
+ // Phase 2 does NOT have a VERIFIED.md
267
+ const phase2 = result.milestones[0].phases[1];
268
+ expect(phase2.verified).toBe(false);
269
+ });
270
+
271
+ it('extracts recent commits via git log', () => {
272
+ const result = projectStatus.getFullStatus('/project');
273
+
274
+ expect(result.recentCommits).toBeDefined();
275
+ expect(Array.isArray(result.recentCommits)).toBe(true);
276
+ expect(result.recentCommits).toHaveLength(3);
277
+
278
+ expect(result.recentCommits[0]).toEqual({
279
+ hash: 'abc1234',
280
+ message: 'feat: add roadmap parser',
281
+ date: '2026-02-09',
282
+ author: 'Jurgen',
283
+ });
284
+ expect(result.recentCommits[1]).toEqual({
285
+ hash: 'def5678',
286
+ message: 'fix: correct milestone grouping',
287
+ date: '2026-02-08',
288
+ author: 'Alice',
289
+ });
290
+ expect(result.recentCommits[2]).toEqual({
291
+ hash: 'ghi9012',
292
+ message: 'test: add project-status tests',
293
+ date: '2026-02-07',
294
+ author: 'Bob',
295
+ });
296
+
297
+ // Verify execSync was called with a git log command
298
+ expect(mockExecSync).toHaveBeenCalled();
299
+ const gitCall = mockExecSync.mock.calls[0][0];
300
+ expect(gitCall).toContain('git log');
301
+ });
302
+
303
+ it('reads project info from package.json and PROJECT.md', () => {
304
+ const result = projectStatus.getFullStatus('/project');
305
+
306
+ expect(result.projectInfo).toBeDefined();
307
+ expect(result.projectInfo.name).toBe('test-project');
308
+ expect(result.projectInfo.version).toBe('2.1.0');
309
+ expect(result.projectInfo.description).toContain('project description paragraph');
310
+ });
311
+
312
+ it('graceful fallback for missing .planning directory', () => {
313
+ // Build a project with no .planning directory at all
314
+ const files = {
315
+ '/empty-project/package.json': JSON.stringify({ name: 'empty', version: '0.1.0' }),
316
+ };
317
+ const fs = createMockFs(files);
318
+ const exec = createMockExecSync('');
319
+ const status = createProjectStatus({ fs, execSync: exec });
320
+
321
+ const result = status.getFullStatus('/empty-project');
322
+
323
+ expect(result).toBeDefined();
324
+ expect(result.milestones).toEqual([]);
325
+ expect(result.totalPhases).toBe(0);
326
+ expect(result.completedPhases).toBe(0);
327
+ expect(result.testSummary).toEqual(expect.objectContaining({
328
+ totalFiles: 0,
329
+ totalTests: 0,
330
+ }));
331
+ expect(result.projectInfo.name).toBe('empty');
332
+ });
333
+
334
+ it('handles heading format with status suffixes', () => {
335
+ const result = projectStatus.getFullStatus('/project');
336
+
337
+ // Phase 1: [x] -> done
338
+ const phase1 = result.milestones[0].phases[0];
339
+ expect(phase1.number).toBe(1);
340
+ expect(phase1.name).toBe('Core Infrastructure');
341
+ expect(phase1.status).toBe('done');
342
+
343
+ // Phase 2: [x] ✓ COMPLETE -> done
344
+ const phase2 = result.milestones[0].phases[1];
345
+ expect(phase2.number).toBe(2);
346
+ expect(phase2.name).toBe('Test Quality');
347
+ expect(phase2.status).toBe('done');
348
+
349
+ // Phase 3: [>] -> in_progress
350
+ const phase3 = result.milestones[0].phases[2];
351
+ expect(phase3.number).toBe(3);
352
+ expect(phase3.name).toBe('Dev Server');
353
+ expect(phase3.status).toBe('in_progress');
354
+
355
+ // Phase 4: [ ] -> pending
356
+ const phase4 = result.milestones[1].phases[0];
357
+ expect(phase4.number).toBe(4);
358
+ expect(phase4.name).toBe('LLM Router');
359
+ expect(phase4.status).toBe('pending');
360
+ });
361
+
362
+ it('returns zero phases for empty roadmap', () => {
363
+ const files = {
364
+ '/minimal/package.json': JSON.stringify({ name: 'minimal', version: '1.0.0' }),
365
+ '/minimal/.planning/ROADMAP.md': '# Roadmap\n\nNothing planned yet.\n',
366
+ '/minimal/.planning/phases': '',
367
+ };
368
+ const fs = createMockFs(files);
369
+ const exec = createMockExecSync('');
370
+ const status = createProjectStatus({ fs, execSync: exec });
371
+
372
+ const result = status.getFullStatus('/minimal');
373
+
374
+ expect(result.totalPhases).toBe(0);
375
+ expect(result.completedPhases).toBe(0);
376
+ expect(result.milestones).toEqual([]);
377
+ });
378
+
379
+ it('falls back to deliverable counts when no PLAN.md exists', () => {
380
+ // Phases 2, 3, 4 have no PLAN.md but have deliverables in ROADMAP.md
381
+ const result = projectStatus.getFullStatus('/project');
382
+
383
+ // Phase 2: 2 deliverables, both [x] → taskCount=2, completedTaskCount=2
384
+ const phase2 = result.milestones[0].phases[1];
385
+ expect(phase2.taskCount).toBe(2);
386
+ expect(phase2.completedTaskCount).toBe(2);
387
+
388
+ // Phase 3: 2 deliverables, 1 [x] and 1 [ ] → taskCount=2, completedTaskCount=1
389
+ const phase3 = result.milestones[0].phases[2];
390
+ expect(phase3.taskCount).toBe(2);
391
+ expect(phase3.completedTaskCount).toBe(1);
392
+
393
+ // Phase 4: 2 deliverables, both [ ] → taskCount=2, completedTaskCount=0
394
+ const phase4 = result.milestones[1].phases[0];
395
+ expect(phase4.taskCount).toBe(2);
396
+ expect(phase4.completedTaskCount).toBe(0);
397
+ });
398
+
399
+ it('parses checklist items without Deliverables header', () => {
400
+ // Kasha-style ROADMAP: - [x] items directly under phase, no **Deliverables:** header
401
+ const roadmap = `# Roadmap
402
+
403
+ ## Milestone: v1.0
404
+
405
+ ### Phase 1: Foundation [x]
406
+ - **Discussion:** assessment.md
407
+ - **Plan:** plan.md
408
+ - [x] 1.1 Update framework
409
+ - [x] 1.2 Standardize Node.js
410
+ - [x] 1.3 Standardize TypeScript
411
+
412
+ ### Phase 2: Backend [>]
413
+ - [x] 2.1 Migration Batch 1
414
+ - [x] 2.2 Migration Batch 2
415
+ - [ ] 2.3 TypeORM standardization
416
+ - [ ] 2.4 Gateway migration
417
+ `;
418
+
419
+ const files = {
420
+ '/kasha/package.json': JSON.stringify({ name: 'kasha', version: '1.0.0' }),
421
+ '/kasha/.planning/ROADMAP.md': roadmap,
422
+ };
423
+ const fs = createMockFs(files);
424
+ const exec = createMockExecSync('');
425
+ const status = createProjectStatus({ fs, execSync: exec });
426
+
427
+ const result = status.getFullStatus('/kasha');
428
+
429
+ // Phase 1: 3 checklist items, all done
430
+ const phase1 = result.milestones[0].phases[0];
431
+ expect(phase1.deliverables).toHaveLength(3);
432
+ expect(phase1.taskCount).toBe(3);
433
+ expect(phase1.completedTaskCount).toBe(3);
434
+
435
+ // Phase 2: 4 checklist items, 2 done
436
+ const phase2 = result.milestones[0].phases[1];
437
+ expect(phase2.deliverables).toHaveLength(4);
438
+ expect(phase2.taskCount).toBe(4);
439
+ expect(phase2.completedTaskCount).toBe(2);
440
+ });
441
+
442
+ it('prefers PLAN.md task counts over deliverable counts', () => {
443
+ // Phase 1 has both a PLAN.md (3 tasks, 2 done) and deliverables (2 items, 2 done)
444
+ // PLAN.md should take precedence
445
+ const result = projectStatus.getFullStatus('/project');
446
+
447
+ const phase1 = result.milestones[0].phases[0];
448
+ expect(phase1.taskCount).toBe(3);
449
+ expect(phase1.completedTaskCount).toBe(2);
450
+ });
451
+
452
+ it('milestone boundary correctly separates phases', () => {
453
+ const result = projectStatus.getFullStatus('/project');
454
+
455
+ // v1.0 milestone should have phases 1, 2, 3
456
+ const v1Phases = result.milestones[0].phases;
457
+ expect(v1Phases.map((p) => p.number)).toEqual([1, 2, 3]);
458
+
459
+ // v2.0 milestone should have phase 4 only
460
+ const v2Phases = result.milestones[1].phases;
461
+ expect(v2Phases.map((p) => p.number)).toEqual([4]);
462
+
463
+ // Phases should not leak across milestone boundaries
464
+ const allPhaseNumbers = result.milestones.flatMap((m) => m.phases.map((p) => p.number));
465
+ expect(allPhaseNumbers).toEqual([1, 2, 3, 4]);
466
+
467
+ // completedPhases should count all [x] phases across milestones
468
+ expect(result.completedPhases).toBe(2);
469
+ });
470
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Projects Registry - Track repos in a workspace
3
+ *
4
+ * Provides a registry for managing multiple projects within a workspace.
5
+ * Supports adding, removing, listing, and auto-detecting projects.
6
+ *
7
+ * @module projects-registry
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { execSync } = require('child_process');
13
+
14
+ const PROJECTS_FILE = 'projects.json';
15
+
16
+ /** Directories to ignore when scanning for repos */
17
+ const IGNORE_DIRS = ['node_modules', '.git', '.svn', '.hg', 'dist', 'build', 'coverage'];
18
+
19
+ /**
20
+ * Validate a git URL (SSH or HTTPS format)
21
+ * @param {string} url - The URL to validate
22
+ * @throws {Error} If the URL is not a valid git URL
23
+ */
24
+ function validateGitUrl(url) {
25
+ // SSH format: git@host:user/repo.git
26
+ const sshPattern = /^git@[\w.-]+:[\w./-]+$/;
27
+ // HTTPS format: https://host/user/repo.git or https://host/user/repo
28
+ const httpsPattern = /^https:\/\/[\w.-]+\/[\w./-]+$/;
29
+
30
+ if (!sshPattern.test(url) && !httpsPattern.test(url)) {
31
+ throw new Error(`Invalid git URL: "${url}". Must be SSH (git@host:user/repo.git) or HTTPS (https://host/user/repo.git)`);
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Ensure a path is relative (strip leading /)
37
+ * @param {string} localPath - The path to normalize
38
+ * @returns {string} Relative path
39
+ */
40
+ function ensureRelativePath(localPath) {
41
+ return localPath.replace(/^\/+/, '');
42
+ }
43
+
44
+ /**
45
+ * Try to extract git remote URL from a repo directory
46
+ * Uses execSync first, falls back to parsing .git/config
47
+ * @param {string} repoAbsPath - Absolute path to the repo
48
+ * @returns {string} Git remote URL or empty string
49
+ */
50
+ function extractGitRemoteUrl(repoAbsPath) {
51
+ // Try execSync first
52
+ try {
53
+ const url = execSync('git remote get-url origin', {
54
+ cwd: repoAbsPath,
55
+ encoding: 'utf-8',
56
+ stdio: ['pipe', 'pipe', 'pipe'],
57
+ }).trim();
58
+ if (url) return url;
59
+ } catch {
60
+ // execSync failed, try parsing .git/config
61
+ }
62
+
63
+ // Fallback: parse .git/config
64
+ try {
65
+ const configPath = path.join(repoAbsPath, '.git', 'config');
66
+ const content = fs.readFileSync(configPath, 'utf-8');
67
+ const match = content.match(/\[remote "origin"\][^[]*url\s*=\s*(.+)/);
68
+ if (match) {
69
+ return match[1].trim();
70
+ }
71
+ } catch {
72
+ // Can't read git config
73
+ }
74
+
75
+ return '';
76
+ }
77
+
78
+ /**
79
+ * Check if a directory has TLC configured
80
+ * @param {string} dirAbsPath - Absolute path to directory
81
+ * @returns {boolean}
82
+ */
83
+ function hasTlcConfig(dirAbsPath) {
84
+ try {
85
+ return fs.statSync(path.join(dirAbsPath, '.tlc.json')).isFile();
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Create a projects registry instance
93
+ * @returns {Object} Registry with load, save, addProject, removeProject, listProjects, detectFromFilesystem methods
94
+ */
95
+ function createProjectsRegistry() {
96
+ /**
97
+ * Load projects.json from a workspace root
98
+ * @param {string} workspaceRoot - Absolute path to workspace
99
+ * @returns {Promise<{version: number, projects: Array}>} Registry data
100
+ */
101
+ async function load(workspaceRoot) {
102
+ const filePath = path.join(workspaceRoot, PROJECTS_FILE);
103
+ try {
104
+ const content = fs.readFileSync(filePath, 'utf-8');
105
+ return JSON.parse(content);
106
+ } catch {
107
+ return { version: 1, projects: [] };
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Save registry data to projects.json atomically (write to temp, then rename)
113
+ * @param {string} workspaceRoot - Absolute path to workspace
114
+ * @param {Object} registryData - Registry data with version and projects
115
+ * @returns {Promise<void>}
116
+ */
117
+ async function save(workspaceRoot, registryData) {
118
+ if (!registryData || typeof registryData.version !== 'number') {
119
+ throw new Error('Registry data must have a "version" field');
120
+ }
121
+
122
+ const filePath = path.join(workspaceRoot, PROJECTS_FILE);
123
+ const tempPath = filePath + '.tmp.' + Date.now();
124
+
125
+ const content = JSON.stringify(registryData, null, 2) + '\n';
126
+ fs.writeFileSync(tempPath, content, 'utf-8');
127
+ fs.renameSync(tempPath, filePath);
128
+ }
129
+
130
+ /**
131
+ * Add a project to the registry
132
+ * @param {string} workspaceRoot - Absolute path to workspace
133
+ * @param {Object} project - Project details
134
+ * @param {string} project.name - Project name
135
+ * @param {string} project.gitUrl - Git remote URL (SSH or HTTPS)
136
+ * @param {string} project.localPath - Relative path within workspace
137
+ * @param {string} project.branch - Default branch name
138
+ * @returns {Promise<void>}
139
+ */
140
+ async function addProject(workspaceRoot, { name, gitUrl, localPath, branch }) {
141
+ validateGitUrl(gitUrl);
142
+
143
+ const registryData = await load(workspaceRoot);
144
+ const existing = registryData.projects.find(p => p.name === name);
145
+ if (existing) {
146
+ throw new Error(`Project "${name}" already exists in registry`);
147
+ }
148
+
149
+ registryData.projects.push({
150
+ name,
151
+ gitUrl,
152
+ localPath: ensureRelativePath(localPath),
153
+ defaultBranch: branch || 'main',
154
+ hasTlc: false,
155
+ description: '',
156
+ });
157
+
158
+ await save(workspaceRoot, registryData);
159
+ }
160
+
161
+ /**
162
+ * Remove a project from the registry by name
163
+ * @param {string} workspaceRoot - Absolute path to workspace
164
+ * @param {string} name - Project name to remove
165
+ * @returns {Promise<void>}
166
+ */
167
+ async function removeProject(workspaceRoot, name) {
168
+ const registryData = await load(workspaceRoot);
169
+ registryData.projects = registryData.projects.filter(p => p.name !== name);
170
+ await save(workspaceRoot, registryData);
171
+ }
172
+
173
+ /**
174
+ * List all projects in the registry
175
+ * @param {string} workspaceRoot - Absolute path to workspace
176
+ * @returns {Promise<Array>} Array of project entries
177
+ */
178
+ async function listProjects(workspaceRoot) {
179
+ const registryData = await load(workspaceRoot);
180
+ return registryData.projects;
181
+ }
182
+
183
+ /**
184
+ * Detect projects from filesystem by scanning for directories with .git/
185
+ * @param {string} workspaceRoot - Absolute path to workspace
186
+ * @returns {Promise<Array>} Array of detected project entries
187
+ */
188
+ async function detectFromFilesystem(workspaceRoot) {
189
+ const detected = [];
190
+
191
+ try {
192
+ const entries = fs.readdirSync(workspaceRoot, { withFileTypes: true });
193
+
194
+ for (const entry of entries) {
195
+ if (!entry.isDirectory()) continue;
196
+ if (IGNORE_DIRS.includes(entry.name)) continue;
197
+
198
+ const dirAbsPath = path.join(workspaceRoot, entry.name);
199
+ const gitDir = path.join(dirAbsPath, '.git');
200
+
201
+ // Only include directories that have a .git/ subdirectory
202
+ try {
203
+ const stat = fs.statSync(gitDir);
204
+ if (!stat.isDirectory()) continue;
205
+ } catch {
206
+ continue;
207
+ }
208
+
209
+ const gitUrl = extractGitRemoteUrl(dirAbsPath);
210
+
211
+ detected.push({
212
+ name: entry.name,
213
+ gitUrl,
214
+ localPath: entry.name,
215
+ defaultBranch: 'main',
216
+ hasTlc: hasTlcConfig(dirAbsPath),
217
+ description: '',
218
+ });
219
+ }
220
+ } catch {
221
+ // Ignore scan errors
222
+ }
223
+
224
+ return detected;
225
+ }
226
+
227
+ return {
228
+ load,
229
+ save,
230
+ addProject,
231
+ removeProject,
232
+ listProjects,
233
+ detectFromFilesystem,
234
+ };
235
+ }
236
+
237
+ module.exports = { createProjectsRegistry };