tlc-claude-code 1.8.5 → 2.1.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 (138) 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/deploy.md +194 -2
  4. package/.claude/commands/tlc/e2e-verify.md +214 -0
  5. package/.claude/commands/tlc/guard.md +191 -0
  6. package/.claude/commands/tlc/help.md +32 -0
  7. package/.claude/commands/tlc/init.md +73 -37
  8. package/.claude/commands/tlc/llm.md +19 -4
  9. package/.claude/commands/tlc/preflight.md +134 -0
  10. package/.claude/commands/tlc/recall.md +87 -0
  11. package/.claude/commands/tlc/remember.md +71 -0
  12. package/.claude/commands/tlc/review.md +17 -4
  13. package/.claude/commands/tlc/watchci.md +159 -0
  14. package/.claude/hooks/tlc-block-tools.sh +41 -0
  15. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  16. package/.claude/hooks/tlc-post-build.sh +38 -0
  17. package/.claude/hooks/tlc-post-push.sh +22 -0
  18. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  19. package/.claude/hooks/tlc-session-init.sh +123 -0
  20. package/CLAUDE.md +96 -201
  21. package/bin/install.js +171 -2
  22. package/bin/postinstall.js +45 -26
  23. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  24. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  25. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  26. package/dashboard-web/dist/index.html +2 -2
  27. package/docker-compose.dev.yml +18 -12
  28. package/package.json +3 -1
  29. package/server/index.js +240 -1
  30. package/server/lib/bug-writer.js +204 -0
  31. package/server/lib/bug-writer.test.js +279 -0
  32. package/server/lib/capture-bridge.js +242 -0
  33. package/server/lib/capture-bridge.test.js +363 -0
  34. package/server/lib/capture-guard.js +140 -0
  35. package/server/lib/capture-guard.test.js +182 -0
  36. package/server/lib/claude-cascade.js +247 -0
  37. package/server/lib/claude-cascade.test.js +245 -0
  38. package/server/lib/command-runner.js +159 -0
  39. package/server/lib/command-runner.test.js +92 -0
  40. package/server/lib/context-injection.js +121 -0
  41. package/server/lib/context-injection.test.js +340 -0
  42. package/server/lib/conversation-chunker.js +320 -0
  43. package/server/lib/conversation-chunker.test.js +573 -0
  44. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  45. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  46. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  47. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  48. package/server/lib/deploy/security-gates.js +11 -24
  49. package/server/lib/deploy/security-gates.test.js +9 -2
  50. package/server/lib/deploy-engine.js +182 -0
  51. package/server/lib/deploy-engine.test.js +147 -0
  52. package/server/lib/docker-api.js +137 -0
  53. package/server/lib/docker-api.test.js +202 -0
  54. package/server/lib/docker-client.js +297 -0
  55. package/server/lib/docker-client.test.js +308 -0
  56. package/server/lib/embedding-client.js +160 -0
  57. package/server/lib/embedding-client.test.js +243 -0
  58. package/server/lib/global-config.js +198 -0
  59. package/server/lib/global-config.test.js +288 -0
  60. package/server/lib/inherited-search.js +184 -0
  61. package/server/lib/inherited-search.test.js +343 -0
  62. package/server/lib/input-sanitizer.js +86 -0
  63. package/server/lib/input-sanitizer.test.js +117 -0
  64. package/server/lib/launchd-agent.js +225 -0
  65. package/server/lib/launchd-agent.test.js +185 -0
  66. package/server/lib/memory-api.js +182 -0
  67. package/server/lib/memory-api.test.js +320 -0
  68. package/server/lib/memory-bridge-e2e.test.js +160 -0
  69. package/server/lib/memory-committer.js +18 -4
  70. package/server/lib/memory-committer.test.js +21 -0
  71. package/server/lib/memory-hooks-capture.test.js +415 -0
  72. package/server/lib/memory-hooks-integration.test.js +98 -0
  73. package/server/lib/memory-hooks.js +139 -0
  74. package/server/lib/memory-inheritance.js +179 -0
  75. package/server/lib/memory-inheritance.test.js +360 -0
  76. package/server/lib/memory-store-adapter.js +105 -0
  77. package/server/lib/memory-store-adapter.test.js +141 -0
  78. package/server/lib/memory-wiring-e2e.test.js +93 -0
  79. package/server/lib/nginx-config.js +114 -0
  80. package/server/lib/nginx-config.test.js +82 -0
  81. package/server/lib/ollama-health.js +91 -0
  82. package/server/lib/ollama-health.test.js +74 -0
  83. package/server/lib/plan-writer.js +196 -0
  84. package/server/lib/plan-writer.test.js +298 -0
  85. package/server/lib/port-guard.js +44 -0
  86. package/server/lib/port-guard.test.js +65 -0
  87. package/server/lib/project-scanner.js +302 -0
  88. package/server/lib/project-scanner.test.js +541 -0
  89. package/server/lib/project-status.js +302 -0
  90. package/server/lib/project-status.test.js +470 -0
  91. package/server/lib/projects-registry.js +237 -0
  92. package/server/lib/projects-registry.test.js +275 -0
  93. package/server/lib/recall-command.js +207 -0
  94. package/server/lib/recall-command.test.js +306 -0
  95. package/server/lib/remember-command.js +98 -0
  96. package/server/lib/remember-command.test.js +288 -0
  97. package/server/lib/rich-capture.js +221 -0
  98. package/server/lib/rich-capture.test.js +312 -0
  99. package/server/lib/roadmap-api.js +200 -0
  100. package/server/lib/roadmap-api.test.js +318 -0
  101. package/server/lib/security/crypto-utils.test.js +2 -2
  102. package/server/lib/semantic-recall.js +242 -0
  103. package/server/lib/semantic-recall.test.js +463 -0
  104. package/server/lib/setup-generator.js +315 -0
  105. package/server/lib/setup-generator.test.js +303 -0
  106. package/server/lib/ssh-client.js +184 -0
  107. package/server/lib/ssh-client.test.js +127 -0
  108. package/server/lib/test-inventory.js +112 -0
  109. package/server/lib/test-inventory.test.js +360 -0
  110. package/server/lib/vector-indexer.js +246 -0
  111. package/server/lib/vector-indexer.test.js +459 -0
  112. package/server/lib/vector-store.js +260 -0
  113. package/server/lib/vector-store.test.js +706 -0
  114. package/server/lib/vps-api.js +184 -0
  115. package/server/lib/vps-api.test.js +208 -0
  116. package/server/lib/vps-bootstrap.js +124 -0
  117. package/server/lib/vps-bootstrap.test.js +79 -0
  118. package/server/lib/vps-monitor.js +126 -0
  119. package/server/lib/vps-monitor.test.js +98 -0
  120. package/server/lib/workspace-api.js +992 -0
  121. package/server/lib/workspace-api.test.js +1217 -0
  122. package/server/lib/workspace-bootstrap.js +164 -0
  123. package/server/lib/workspace-bootstrap.test.js +503 -0
  124. package/server/lib/workspace-context.js +129 -0
  125. package/server/lib/workspace-context.test.js +214 -0
  126. package/server/lib/workspace-detector.js +162 -0
  127. package/server/lib/workspace-detector.test.js +193 -0
  128. package/server/lib/workspace-init.js +307 -0
  129. package/server/lib/workspace-init.test.js +244 -0
  130. package/server/lib/workspace-snapshot.js +236 -0
  131. package/server/lib/workspace-snapshot.test.js +444 -0
  132. package/server/lib/workspace-watcher.js +162 -0
  133. package/server/lib/workspace-watcher.test.js +257 -0
  134. package/server/package-lock.json +1306 -17
  135. package/server/package.json +7 -0
  136. package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
  137. package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
  138. package/dashboard-web/dist/assets/index-Trhg1C1Y.css +0 -1
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Workspace Watcher - Auto-detect and register new repos in a workspace
3
+ *
4
+ * Scans a workspace root directory for new git repositories, registers them
5
+ * in the projects registry, and broadcasts WebSocket events on detection.
6
+ * Also detects removed repos (directories deleted from disk but still in registry).
7
+ *
8
+ * Task 5 (Phase 72): Auto-Detect & Register New Repos
9
+ */
10
+
11
+ import fs from 'fs';
12
+ import path from 'path';
13
+
14
+ /**
15
+ * Extracts the git remote origin URL from a repo's .git/config file.
16
+ * @param {string} repoPath - Absolute path to the repo directory
17
+ * @returns {string|null} The remote origin URL or null if not found
18
+ */
19
+ function extractGitRemoteUrl(repoPath) {
20
+ const configPath = path.join(repoPath, '.git', 'config');
21
+ try {
22
+ const content = fs.readFileSync(configPath, 'utf-8');
23
+ const match = content.match(/\[remote\s+"origin"\][^[]*url\s*=\s*(.+)/);
24
+ if (match) {
25
+ return match[1].trim();
26
+ }
27
+ } catch {
28
+ // No config file or unreadable — return null
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Checks whether a directory contains a .git subdirectory.
35
+ * @param {string} dirPath - Absolute path to the directory
36
+ * @returns {boolean} True if a .git directory exists inside dirPath
37
+ */
38
+ function isGitRepo(dirPath) {
39
+ try {
40
+ const gitDir = path.join(dirPath, '.git');
41
+ const stat = fs.statSync(gitDir);
42
+ return stat.isDirectory();
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Creates a workspace watcher that scans for new and removed git repositories.
50
+ *
51
+ * @param {object} options - Configuration options
52
+ * @param {object} options.registry - Projects registry with addProject/removeProject/listProjects
53
+ * @param {Function} [options.broadcast] - Optional WebSocket broadcast function
54
+ * @param {boolean} [options.enabled=true] - Whether the watcher is enabled
55
+ * @param {number} [options.debounceMs=500] - Debounce interval in milliseconds (for future fs.watch)
56
+ * @returns {object} Watcher with start(), stop(), and scan() methods
57
+ */
58
+ export function createWorkspaceWatcher({ registry, broadcast, enabled = true, debounceMs = 500 } = {}) {
59
+ let watching = false;
60
+
61
+ /**
62
+ * Start watching the workspace root for filesystem changes.
63
+ * Placeholder for future fs.watch integration.
64
+ * @param {string} workspaceRoot - Absolute path to the workspace directory
65
+ */
66
+ function start(workspaceRoot) {
67
+ watching = true;
68
+ }
69
+
70
+ /**
71
+ * Stop watching the workspace root.
72
+ */
73
+ function stop() {
74
+ watching = false;
75
+ }
76
+
77
+ /**
78
+ * Scan the workspace root for new and removed git repositories.
79
+ *
80
+ * - Lists all subdirectories in workspaceRoot
81
+ * - For each directory with .git/: if not already registered, extract remote URL,
82
+ * call registry.addProject, and optionally broadcast a 'new-project' event
83
+ * - For each project in the registry that no longer exists on disk,
84
+ * call registry.removeProject
85
+ *
86
+ * @param {string} workspaceRoot - Absolute path to the workspace directory
87
+ * @returns {Promise<Array<{name: string, gitUrl: string|null}>>} Newly detected repos
88
+ */
89
+ async function scan(workspaceRoot) {
90
+ if (!enabled) {
91
+ return [];
92
+ }
93
+
94
+ // Get currently registered projects
95
+ const existingProjects = registry.listProjects();
96
+ const existingNames = new Set(
97
+ (Array.isArray(existingProjects) ? existingProjects : []).map((p) => p.name)
98
+ );
99
+
100
+ const newRepos = [];
101
+
102
+ // Read directories in workspaceRoot
103
+ let entries;
104
+ try {
105
+ entries = fs.readdirSync(workspaceRoot, { withFileTypes: true });
106
+ } catch {
107
+ return [];
108
+ }
109
+
110
+ // Collect names of directories that exist on disk (with .git/)
111
+ const onDiskRepoNames = new Set();
112
+
113
+ for (const entry of entries) {
114
+ if (!entry.isDirectory()) continue;
115
+
116
+ const dirPath = path.join(workspaceRoot, entry.name);
117
+
118
+ if (!isGitRepo(dirPath)) continue;
119
+
120
+ onDiskRepoNames.add(entry.name);
121
+
122
+ // Skip repos already in the registry
123
+ if (existingNames.has(entry.name)) continue;
124
+
125
+ const gitUrl = extractGitRemoteUrl(dirPath) || null;
126
+
127
+ const projectInfo = {
128
+ name: entry.name,
129
+ gitUrl,
130
+ };
131
+
132
+ // Register with the projects registry
133
+ registry.addProject(workspaceRoot, projectInfo);
134
+
135
+ // Broadcast WebSocket event if broadcast function provided
136
+ if (typeof broadcast === 'function') {
137
+ broadcast({
138
+ type: 'new-project',
139
+ project: projectInfo,
140
+ });
141
+ }
142
+
143
+ newRepos.push(projectInfo);
144
+ }
145
+
146
+ // Detect removed repos: in registry but no longer on disk
147
+ const registeredProjects = Array.isArray(existingProjects) ? existingProjects : [];
148
+ for (const project of registeredProjects) {
149
+ if (!onDiskRepoNames.has(project.name)) {
150
+ registry.removeProject(workspaceRoot, project.name);
151
+ }
152
+ }
153
+
154
+ return newRepos;
155
+ }
156
+
157
+ return {
158
+ start,
159
+ stop,
160
+ scan,
161
+ };
162
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Workspace Watcher Tests
3
+ * Tests for auto-detecting and registering new repos in a workspace.
4
+ *
5
+ * Task 5 (Phase 72): Auto-Detect & Register New Repos
6
+ * - Detects new directories with .git/ via scan()
7
+ * - Adds detected repos to projects.json via registry.addProject
8
+ * - Extracts git remote URL from new repos
9
+ * - Broadcasts WebSocket event when new project detected
10
+ * - Ignores directories without .git/
11
+ * - Configurable enable/disable
12
+ * - Handles rapid successive additions (multiple new repos)
13
+ * - Handles directory deletion (removed repos)
14
+ * - Returns list of newly detected repos from scan
15
+ */
16
+
17
+ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import os from 'os';
21
+ import { createWorkspaceWatcher } from './workspace-watcher.js';
22
+
23
+ /**
24
+ * Creates a mock projects registry with vi.fn() methods.
25
+ * @returns {object} Mock registry
26
+ */
27
+ function createMockRegistry() {
28
+ return {
29
+ load: vi.fn().mockReturnValue({ version: 1, projects: [] }),
30
+ save: vi.fn(),
31
+ addProject: vi.fn(),
32
+ removeProject: vi.fn(),
33
+ listProjects: vi.fn().mockReturnValue([]),
34
+ detectFromFilesystem: vi.fn().mockReturnValue([]),
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Creates a fake git repo directory with a .git/ subdirectory.
40
+ * Optionally writes a git config file with a remote origin URL.
41
+ * @param {string} parentDir - Parent directory to create the repo in
42
+ * @param {string} repoName - Name of the repo directory
43
+ * @param {string} [remoteUrl] - Optional git remote origin URL
44
+ * @returns {string} Full path to the created repo directory
45
+ */
46
+ function createFakeRepo(parentDir, repoName, remoteUrl) {
47
+ const repoPath = path.join(parentDir, repoName);
48
+ fs.mkdirSync(repoPath, { recursive: true });
49
+ fs.mkdirSync(path.join(repoPath, '.git'), { recursive: true });
50
+ if (remoteUrl) {
51
+ const configContent = [
52
+ '[remote "origin"]',
53
+ `\turl = ${remoteUrl}`,
54
+ '\tfetch = +refs/heads/*:refs/remotes/origin/*',
55
+ ].join('\n');
56
+ fs.writeFileSync(path.join(repoPath, '.git', 'config'), configContent);
57
+ }
58
+ return repoPath;
59
+ }
60
+
61
+ /**
62
+ * Creates a plain directory (not a git repo) inside a parent directory.
63
+ * @param {string} parentDir - Parent directory
64
+ * @param {string} dirName - Directory name
65
+ * @returns {string} Full path to the created directory
66
+ */
67
+ function createPlainDir(parentDir, dirName) {
68
+ const dirPath = path.join(parentDir, dirName);
69
+ fs.mkdirSync(dirPath, { recursive: true });
70
+ return dirPath;
71
+ }
72
+
73
+ describe('workspace-watcher', () => {
74
+ let tempDir;
75
+ let registry;
76
+ let broadcast;
77
+
78
+ beforeEach(() => {
79
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'workspace-watcher-test-'));
80
+ registry = createMockRegistry();
81
+ broadcast = vi.fn();
82
+ });
83
+
84
+ afterEach(() => {
85
+ fs.rmSync(tempDir, { recursive: true, force: true });
86
+ });
87
+
88
+ describe('scan detection', () => {
89
+ it('should detect new directory with .git/', async () => {
90
+ createFakeRepo(tempDir, 'my-new-repo');
91
+
92
+ const watcher = createWorkspaceWatcher({ registry, broadcast });
93
+ const newRepos = await watcher.scan(tempDir);
94
+
95
+ expect(newRepos).toHaveLength(1);
96
+ expect(newRepos[0]).toHaveProperty('name', 'my-new-repo');
97
+ });
98
+
99
+ it('should ignore directories without .git/', async () => {
100
+ createPlainDir(tempDir, 'not-a-repo');
101
+ createPlainDir(tempDir, 'also-not-a-repo');
102
+ createFakeRepo(tempDir, 'actual-repo');
103
+
104
+ const watcher = createWorkspaceWatcher({ registry, broadcast });
105
+ const newRepos = await watcher.scan(tempDir);
106
+
107
+ expect(newRepos).toHaveLength(1);
108
+ expect(newRepos[0]).toHaveProperty('name', 'actual-repo');
109
+ });
110
+
111
+ it('should return list of newly detected repos from scan', async () => {
112
+ createFakeRepo(tempDir, 'repo-alpha', 'git@github.com:user/alpha.git');
113
+ createFakeRepo(tempDir, 'repo-beta', 'https://github.com/user/beta.git');
114
+
115
+ const watcher = createWorkspaceWatcher({ registry, broadcast });
116
+ const newRepos = await watcher.scan(tempDir);
117
+
118
+ expect(newRepos).toHaveLength(2);
119
+ const names = newRepos.map((r) => r.name);
120
+ expect(names).toContain('repo-alpha');
121
+ expect(names).toContain('repo-beta');
122
+ });
123
+ });
124
+
125
+ describe('registry integration', () => {
126
+ it('should add detected repo to projects.json via registry.addProject', async () => {
127
+ createFakeRepo(tempDir, 'new-project', 'git@github.com:org/new-project.git');
128
+
129
+ const watcher = createWorkspaceWatcher({ registry, broadcast });
130
+ await watcher.scan(tempDir);
131
+
132
+ expect(registry.addProject).toHaveBeenCalledTimes(1);
133
+ expect(registry.addProject).toHaveBeenCalledWith(
134
+ tempDir,
135
+ expect.objectContaining({
136
+ name: 'new-project',
137
+ })
138
+ );
139
+ });
140
+
141
+ it('should extract git remote URL from new repo', async () => {
142
+ const remoteUrl = 'git@github.com:myorg/my-service.git';
143
+ createFakeRepo(tempDir, 'my-service', remoteUrl);
144
+
145
+ const watcher = createWorkspaceWatcher({ registry, broadcast });
146
+ const newRepos = await watcher.scan(tempDir);
147
+
148
+ expect(newRepos[0]).toHaveProperty('gitUrl', remoteUrl);
149
+ expect(registry.addProject).toHaveBeenCalledWith(
150
+ tempDir,
151
+ expect.objectContaining({
152
+ gitUrl: remoteUrl,
153
+ })
154
+ );
155
+ });
156
+ });
157
+
158
+ describe('WebSocket broadcast', () => {
159
+ it('should broadcast event when new project detected', async () => {
160
+ createFakeRepo(tempDir, 'detected-repo', 'git@github.com:org/detected.git');
161
+
162
+ const watcher = createWorkspaceWatcher({ registry, broadcast });
163
+ await watcher.scan(tempDir);
164
+
165
+ expect(broadcast).toHaveBeenCalledTimes(1);
166
+ expect(broadcast).toHaveBeenCalledWith(
167
+ expect.objectContaining({
168
+ type: 'new-project',
169
+ project: expect.objectContaining({
170
+ name: 'detected-repo',
171
+ }),
172
+ })
173
+ );
174
+ });
175
+
176
+ it('should work without broadcast function (optional)', async () => {
177
+ createFakeRepo(tempDir, 'no-broadcast-repo');
178
+
179
+ const watcher = createWorkspaceWatcher({ registry });
180
+ const newRepos = await watcher.scan(tempDir);
181
+
182
+ // Should not throw, should still detect
183
+ expect(newRepos).toHaveLength(1);
184
+ expect(registry.addProject).toHaveBeenCalledTimes(1);
185
+ });
186
+ });
187
+
188
+ describe('enable/disable configuration', () => {
189
+ it('should return empty when disabled', async () => {
190
+ createFakeRepo(tempDir, 'invisible-repo');
191
+
192
+ const watcher = createWorkspaceWatcher({
193
+ registry,
194
+ broadcast,
195
+ enabled: false,
196
+ });
197
+ const newRepos = await watcher.scan(tempDir);
198
+
199
+ expect(newRepos).toHaveLength(0);
200
+ expect(registry.addProject).not.toHaveBeenCalled();
201
+ expect(broadcast).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it('should detect repos when enabled (default)', async () => {
205
+ createFakeRepo(tempDir, 'visible-repo');
206
+
207
+ const watcher = createWorkspaceWatcher({ registry, broadcast });
208
+ const newRepos = await watcher.scan(tempDir);
209
+
210
+ expect(newRepos).toHaveLength(1);
211
+ });
212
+ });
213
+
214
+ describe('multiple repos', () => {
215
+ it('should handle rapid successive additions (multiple new repos at once)', async () => {
216
+ createFakeRepo(tempDir, 'repo-one', 'git@github.com:org/one.git');
217
+ createFakeRepo(tempDir, 'repo-two', 'git@github.com:org/two.git');
218
+ createFakeRepo(tempDir, 'repo-three', 'git@github.com:org/three.git');
219
+ // Also add a non-repo directory to make sure it is skipped
220
+ createPlainDir(tempDir, 'some-folder');
221
+
222
+ const watcher = createWorkspaceWatcher({ registry, broadcast });
223
+ const newRepos = await watcher.scan(tempDir);
224
+
225
+ expect(newRepos).toHaveLength(3);
226
+ expect(registry.addProject).toHaveBeenCalledTimes(3);
227
+ expect(broadcast).toHaveBeenCalledTimes(3);
228
+
229
+ const names = newRepos.map((r) => r.name).sort();
230
+ expect(names).toEqual(['repo-one', 'repo-three', 'repo-two']);
231
+ });
232
+ });
233
+
234
+ describe('directory deletion', () => {
235
+ it('should detect removed repos when directory is deleted', async () => {
236
+ // Set up registry to report a project that no longer exists on disk
237
+ const missingProject = {
238
+ name: 'deleted-repo',
239
+ localPath: 'deleted-repo',
240
+ gitUrl: 'git@github.com:org/deleted.git',
241
+ };
242
+ registry.listProjects.mockReturnValue([missingProject]);
243
+
244
+ // The directory does NOT exist on disk — it was deleted
245
+ // (we do not create it in tempDir)
246
+
247
+ const watcher = createWorkspaceWatcher({ registry, broadcast });
248
+ const newRepos = await watcher.scan(tempDir);
249
+
250
+ // No new repos detected (nothing on disk with .git/)
251
+ expect(newRepos).toHaveLength(0);
252
+
253
+ // The watcher should call removeProject for the missing repo
254
+ expect(registry.removeProject).toHaveBeenCalledWith(tempDir, 'deleted-repo');
255
+ });
256
+ });
257
+ });