tlc-claude-code 2.0.1 → 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 (86) hide show
  1. package/.claude/commands/tlc/deploy.md +194 -2
  2. package/.claude/commands/tlc/e2e-verify.md +214 -0
  3. package/.claude/commands/tlc/guard.md +191 -0
  4. package/.claude/commands/tlc/help.md +32 -0
  5. package/.claude/commands/tlc/init.md +73 -37
  6. package/.claude/commands/tlc/llm.md +19 -4
  7. package/.claude/commands/tlc/preflight.md +134 -0
  8. package/.claude/commands/tlc/review.md +17 -4
  9. package/.claude/commands/tlc/watchci.md +159 -0
  10. package/.claude/hooks/tlc-block-tools.sh +41 -0
  11. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  12. package/.claude/hooks/tlc-post-build.sh +38 -0
  13. package/.claude/hooks/tlc-post-push.sh +22 -0
  14. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  15. package/.claude/hooks/tlc-session-init.sh +123 -0
  16. package/CLAUDE.md +12 -0
  17. package/bin/install.js +171 -2
  18. package/bin/postinstall.js +45 -26
  19. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  20. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  21. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  22. package/dashboard-web/dist/index.html +2 -2
  23. package/docker-compose.dev.yml +18 -12
  24. package/package.json +3 -1
  25. package/server/index.js +228 -2
  26. package/server/lib/capture-bridge.js +242 -0
  27. package/server/lib/capture-bridge.test.js +363 -0
  28. package/server/lib/capture-guard.js +140 -0
  29. package/server/lib/capture-guard.test.js +182 -0
  30. package/server/lib/command-runner.js +159 -0
  31. package/server/lib/command-runner.test.js +92 -0
  32. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  33. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  34. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  35. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  36. package/server/lib/deploy/security-gates.js +11 -24
  37. package/server/lib/deploy/security-gates.test.js +9 -2
  38. package/server/lib/deploy-engine.js +182 -0
  39. package/server/lib/deploy-engine.test.js +147 -0
  40. package/server/lib/docker-api.js +137 -0
  41. package/server/lib/docker-api.test.js +202 -0
  42. package/server/lib/docker-client.js +297 -0
  43. package/server/lib/docker-client.test.js +308 -0
  44. package/server/lib/input-sanitizer.js +86 -0
  45. package/server/lib/input-sanitizer.test.js +117 -0
  46. package/server/lib/launchd-agent.js +225 -0
  47. package/server/lib/launchd-agent.test.js +185 -0
  48. package/server/lib/memory-api.js +3 -1
  49. package/server/lib/memory-api.test.js +3 -5
  50. package/server/lib/memory-bridge-e2e.test.js +160 -0
  51. package/server/lib/memory-committer.js +18 -4
  52. package/server/lib/memory-committer.test.js +21 -0
  53. package/server/lib/memory-hooks-capture.test.js +69 -4
  54. package/server/lib/memory-hooks-integration.test.js +98 -0
  55. package/server/lib/memory-hooks.js +42 -4
  56. package/server/lib/memory-store-adapter.js +105 -0
  57. package/server/lib/memory-store-adapter.test.js +141 -0
  58. package/server/lib/memory-wiring-e2e.test.js +93 -0
  59. package/server/lib/nginx-config.js +114 -0
  60. package/server/lib/nginx-config.test.js +82 -0
  61. package/server/lib/ollama-health.js +91 -0
  62. package/server/lib/ollama-health.test.js +74 -0
  63. package/server/lib/port-guard.js +44 -0
  64. package/server/lib/port-guard.test.js +65 -0
  65. package/server/lib/project-scanner.js +37 -2
  66. package/server/lib/project-scanner.test.js +152 -0
  67. package/server/lib/remember-command.js +2 -0
  68. package/server/lib/remember-command.test.js +23 -0
  69. package/server/lib/security/crypto-utils.test.js +2 -2
  70. package/server/lib/semantic-recall.js +1 -1
  71. package/server/lib/semantic-recall.test.js +17 -0
  72. package/server/lib/ssh-client.js +184 -0
  73. package/server/lib/ssh-client.test.js +127 -0
  74. package/server/lib/vps-api.js +184 -0
  75. package/server/lib/vps-api.test.js +208 -0
  76. package/server/lib/vps-bootstrap.js +124 -0
  77. package/server/lib/vps-bootstrap.test.js +79 -0
  78. package/server/lib/vps-monitor.js +126 -0
  79. package/server/lib/vps-monitor.test.js +98 -0
  80. package/server/lib/workspace-api.js +182 -1
  81. package/server/lib/workspace-api.test.js +474 -0
  82. package/server/package-lock.json +737 -0
  83. package/server/package.json +3 -0
  84. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  85. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  86. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Nginx Config Generator — server blocks, wildcard routing, SSL
3
+ * Phase 80 Task 5 (replaces caddy-config.js)
4
+ */
5
+
6
+ const { isValidDomain } = require('./input-sanitizer.js');
7
+
8
+ /**
9
+ * Generate Nginx site config for a project
10
+ * @param {Object} options
11
+ * @param {string} options.domain - Server domain
12
+ * @param {number} options.port - App port
13
+ * @param {string} options.proxyPass - Upstream URL
14
+ * @returns {string} Nginx config
15
+ */
16
+ function generateSiteConfig({ domain, port, proxyPass }) {
17
+ if (!isValidDomain(domain)) throw new Error(`Invalid domain: ${domain}`);
18
+ return `# TLC generated Nginx config for ${domain}
19
+ server {
20
+ listen 80;
21
+ server_name ${domain};
22
+
23
+ location / {
24
+ proxy_pass ${proxyPass};
25
+
26
+ # Proxy headers
27
+ proxy_set_header Host $host;
28
+ proxy_set_header X-Real-IP $remote_addr;
29
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
30
+ proxy_set_header X-Forwarded-Proto $scheme;
31
+
32
+ # WebSocket support
33
+ proxy_http_version 1.1;
34
+ proxy_set_header Upgrade $http_upgrade;
35
+ proxy_set_header Connection "upgrade";
36
+
37
+ # Timeouts
38
+ proxy_connect_timeout 60s;
39
+ proxy_send_timeout 60s;
40
+ proxy_read_timeout 60s;
41
+ }
42
+ }
43
+ `;
44
+ }
45
+
46
+ /**
47
+ * Generate wildcard Nginx config for branch previews
48
+ * @param {string} baseDomain - Base domain (e.g. myapp.dev)
49
+ * @param {Object} options
50
+ * @param {Array} [options.branches] - [{ subdomain, port }]
51
+ * @returns {string} Nginx config
52
+ */
53
+ function generateWildcardConfig(baseDomain, options = {}) {
54
+ if (!isValidDomain(baseDomain)) throw new Error(`Invalid domain: ${baseDomain}`);
55
+ const branches = options.branches || [];
56
+
57
+ // Map blocks for each branch
58
+ const mapEntries = branches
59
+ .map(b => ` ${b.subdomain}.${baseDomain} 127.0.0.1:${b.port};`)
60
+ .join('\n');
61
+
62
+ return `# TLC wildcard config for *.${baseDomain}
63
+
64
+ map $host $branch_upstream {
65
+ default "";
66
+ ${mapEntries}
67
+ }
68
+
69
+ # Default server for unknown subdomains
70
+ server {
71
+ listen 80 default_server;
72
+ server_name *.${baseDomain};
73
+
74
+ location / {
75
+ if ($branch_upstream = "") {
76
+ return 404 "No deployment for this branch";
77
+ }
78
+ proxy_pass http://$branch_upstream;
79
+
80
+ proxy_set_header Host $host;
81
+ proxy_set_header X-Real-IP $remote_addr;
82
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
83
+ proxy_set_header X-Forwarded-Proto $scheme;
84
+
85
+ proxy_http_version 1.1;
86
+ proxy_set_header Upgrade $http_upgrade;
87
+ proxy_set_header Connection "upgrade";
88
+ }
89
+ }
90
+ `;
91
+ }
92
+
93
+ /**
94
+ * Generate SSL config snippet for a domain
95
+ * @param {string} domain
96
+ * @returns {string} SSL config lines
97
+ */
98
+ function generateSslConfig(domain) {
99
+ if (!isValidDomain(domain)) throw new Error(`Invalid domain: ${domain}`);
100
+ return ` # SSL Configuration for ${domain}
101
+ listen 443 ssl;
102
+ ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;
103
+ ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;
104
+
105
+ ssl_protocols TLSv1.2 TLSv1.3;
106
+ ssl_prefer_server_ciphers on;
107
+ ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
108
+
109
+ # HSTS
110
+ add_header Strict-Transport-Security "max-age=63072000" always;
111
+ `;
112
+ }
113
+
114
+ module.exports = { generateSiteConfig, generateWildcardConfig, generateSslConfig };
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from 'vitest';
2
+
3
+ const { generateSiteConfig, generateWildcardConfig, generateSslConfig } = await import('./nginx-config.js');
4
+
5
+ describe('Nginx Config Generator', () => {
6
+ describe('generateSiteConfig', () => {
7
+ it('produces valid Nginx server block', () => {
8
+ const config = generateSiteConfig({
9
+ domain: 'myapp.dev',
10
+ port: 3000,
11
+ proxyPass: 'http://127.0.0.1:3000',
12
+ });
13
+ expect(config).toContain('server {');
14
+ expect(config).toContain('server_name myapp.dev');
15
+ expect(config).toContain('proxy_pass http://127.0.0.1:3000');
16
+ });
17
+
18
+ it('includes proxy headers', () => {
19
+ const config = generateSiteConfig({
20
+ domain: 'myapp.dev',
21
+ port: 3000,
22
+ proxyPass: 'http://127.0.0.1:3000',
23
+ });
24
+ expect(config).toContain('proxy_set_header Host');
25
+ expect(config).toContain('proxy_set_header X-Real-IP');
26
+ expect(config).toContain('proxy_set_header X-Forwarded-For');
27
+ });
28
+
29
+ it('includes WebSocket upgrade headers', () => {
30
+ const config = generateSiteConfig({
31
+ domain: 'myapp.dev',
32
+ port: 3000,
33
+ proxyPass: 'http://127.0.0.1:3000',
34
+ });
35
+ expect(config).toContain('proxy_http_version 1.1');
36
+ expect(config).toContain('Upgrade');
37
+ expect(config).toContain('Connection');
38
+ });
39
+
40
+ it('listens on port 80 by default', () => {
41
+ const config = generateSiteConfig({
42
+ domain: 'myapp.dev',
43
+ port: 3000,
44
+ proxyPass: 'http://127.0.0.1:3000',
45
+ });
46
+ expect(config).toContain('listen 80');
47
+ });
48
+ });
49
+
50
+ describe('generateWildcardConfig', () => {
51
+ it('routes subdomains to container ports', () => {
52
+ const config = generateWildcardConfig('myapp.dev', {
53
+ branches: [
54
+ { subdomain: 'feat-login', port: 4001 },
55
+ { subdomain: 'main', port: 4000 },
56
+ ],
57
+ });
58
+ expect(config).toContain('*.myapp.dev');
59
+ expect(config).toContain('feat-login');
60
+ expect(config).toContain('4001');
61
+ });
62
+
63
+ it('includes default server for unknown subdomains', () => {
64
+ const config = generateWildcardConfig('myapp.dev', { branches: [] });
65
+ expect(config).toContain('default_server');
66
+ });
67
+ });
68
+
69
+ describe('generateSslConfig', () => {
70
+ it('references Lets Encrypt certificate paths', () => {
71
+ const config = generateSslConfig('myapp.dev');
72
+ expect(config).toContain('ssl_certificate');
73
+ expect(config).toContain('/etc/letsencrypt');
74
+ expect(config).toContain('myapp.dev');
75
+ });
76
+
77
+ it('includes SSL security settings', () => {
78
+ const config = generateSslConfig('myapp.dev');
79
+ expect(config).toContain('ssl_protocols');
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Ollama health checker.
3
+ *
4
+ * Checks whether Ollama is installed, running, and has the required
5
+ * embedding model. Returns actionable messages for each failure state.
6
+ * Results are cached for 60 seconds.
7
+ *
8
+ * @module ollama-health
9
+ */
10
+
11
+ const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
12
+ const REQUIRED_MODEL = 'mxbai-embed-large';
13
+ const CACHE_TTL_MS = 60 * 1000;
14
+
15
+ /** @enum {string} */
16
+ const OLLAMA_STATUS = {
17
+ READY: 'ready',
18
+ NOT_INSTALLED: 'not_installed',
19
+ NOT_RUNNING: 'not_running',
20
+ NO_MODEL: 'no_model',
21
+ };
22
+
23
+ let cachedResult = null;
24
+ let cachedAt = 0;
25
+
26
+ /**
27
+ * Check Ollama health status.
28
+ *
29
+ * @param {object} [deps] - Injectable dependencies for testing
30
+ * @param {Function} [deps.fetch] - Fetch implementation
31
+ * @returns {Promise<{status: string, message: string, action: string}>}
32
+ */
33
+ async function checkOllamaHealth(deps = {}) {
34
+ const now = Date.now();
35
+ if (cachedResult && (now - cachedAt) < CACHE_TTL_MS) {
36
+ return cachedResult;
37
+ }
38
+
39
+ const fetchFn = deps.fetch || globalThis.fetch;
40
+
41
+ try {
42
+ const response = await fetchFn(`${OLLAMA_URL}/api/tags`, {
43
+ signal: AbortSignal.timeout(3000),
44
+ });
45
+
46
+ if (!response.ok) {
47
+ cachedResult = {
48
+ status: OLLAMA_STATUS.NOT_RUNNING,
49
+ message: 'Ollama responded with an error',
50
+ action: 'Restart Ollama: ollama serve',
51
+ };
52
+ cachedAt = now;
53
+ return cachedResult;
54
+ }
55
+
56
+ const data = await response.json();
57
+ const models = data.models || [];
58
+ const hasModel = models.some(m => m.name && m.name.startsWith(REQUIRED_MODEL));
59
+
60
+ if (hasModel) {
61
+ cachedResult = {
62
+ status: OLLAMA_STATUS.READY,
63
+ message: 'Memory: full (pattern detection + semantic search)',
64
+ action: '',
65
+ };
66
+ } else {
67
+ cachedResult = {
68
+ status: OLLAMA_STATUS.NO_MODEL,
69
+ message: `Ollama running but ${REQUIRED_MODEL} model not found`,
70
+ action: `ollama pull ${REQUIRED_MODEL}`,
71
+ };
72
+ }
73
+ } catch {
74
+ cachedResult = {
75
+ status: OLLAMA_STATUS.NOT_RUNNING,
76
+ message: 'Ollama not running. Semantic search disabled, pattern detection still works.',
77
+ action: 'brew install ollama && ollama serve && ollama pull mxbai-embed-large',
78
+ };
79
+ }
80
+
81
+ cachedAt = now;
82
+ return cachedResult;
83
+ }
84
+
85
+ /** Clear the cache (for testing). */
86
+ checkOllamaHealth._clearCache = function () {
87
+ cachedResult = null;
88
+ cachedAt = 0;
89
+ };
90
+
91
+ module.exports = { checkOllamaHealth, OLLAMA_STATUS };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Ollama health checker tests - Phase 84 Task 1
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+
7
+ import { checkOllamaHealth, OLLAMA_STATUS } from './ollama-health.js';
8
+
9
+ describe('ollama-health', () => {
10
+ beforeEach(() => {
11
+ // Clear cache between tests
12
+ checkOllamaHealth._clearCache?.();
13
+ });
14
+
15
+ it('returns ready when Ollama responds with correct model', async () => {
16
+ const mockFetch = vi.fn().mockResolvedValue({
17
+ ok: true,
18
+ json: async () => ({ models: [{ name: 'mxbai-embed-large:latest' }] }),
19
+ });
20
+
21
+ const result = await checkOllamaHealth({ fetch: mockFetch });
22
+ expect(result.status).toBe(OLLAMA_STATUS.READY);
23
+ expect(result.message).toContain('full');
24
+ });
25
+
26
+ it('returns not_running when connection refused', async () => {
27
+ const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
28
+
29
+ const result = await checkOllamaHealth({ fetch: mockFetch });
30
+ expect([OLLAMA_STATUS.NOT_INSTALLED, OLLAMA_STATUS.NOT_RUNNING]).toContain(result.status);
31
+ expect(result.action).toBeDefined();
32
+ });
33
+
34
+ it('returns no_model when Ollama responds but model missing', async () => {
35
+ const mockFetch = vi.fn().mockResolvedValue({
36
+ ok: true,
37
+ json: async () => ({ models: [{ name: 'llama3:latest' }] }),
38
+ });
39
+
40
+ const result = await checkOllamaHealth({ fetch: mockFetch });
41
+ expect(result.status).toBe(OLLAMA_STATUS.NO_MODEL);
42
+ expect(result.action).toContain('ollama pull');
43
+ });
44
+
45
+ it('caches result within 60s window', async () => {
46
+ const mockFetch = vi.fn().mockResolvedValue({
47
+ ok: true,
48
+ json: async () => ({ models: [{ name: 'mxbai-embed-large:latest' }] }),
49
+ });
50
+
51
+ const result1 = await checkOllamaHealth({ fetch: mockFetch });
52
+ const result2 = await checkOllamaHealth({ fetch: mockFetch });
53
+
54
+ expect(result1.status).toBe(result2.status);
55
+ // Should only call fetch once due to caching
56
+ expect(mockFetch).toHaveBeenCalledTimes(1);
57
+ });
58
+
59
+ it('returns actionable message for each status', async () => {
60
+ const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
61
+
62
+ const result = await checkOllamaHealth({ fetch: mockFetch });
63
+ expect(result.action).toBeTruthy();
64
+ expect(typeof result.action).toBe('string');
65
+ expect(result.message).toBeTruthy();
66
+ });
67
+
68
+ it('exports status constants', () => {
69
+ expect(OLLAMA_STATUS.READY).toBe('ready');
70
+ expect(OLLAMA_STATUS.NOT_INSTALLED).toBe('not_installed');
71
+ expect(OLLAMA_STATUS.NOT_RUNNING).toBe('not_running');
72
+ expect(OLLAMA_STATUS.NO_MODEL).toBe('no_model');
73
+ });
74
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Port guard - checks if a port is available before server startup.
3
+ *
4
+ * Detects port conflicts and reports which process holds the port.
5
+ * Designed for use with launchd ThrottleInterval to prevent restart spam.
6
+ *
7
+ * @module port-guard
8
+ */
9
+
10
+ const net = require('net');
11
+
12
+ /**
13
+ * Check if a port is available.
14
+ *
15
+ * @param {number} port - Port number to check
16
+ * @returns {Promise<{available: boolean, port: number, pid?: number, command?: string}>}
17
+ */
18
+ async function checkPort(port) {
19
+ return new Promise((resolve) => {
20
+ const server = net.createServer();
21
+
22
+ server.once('error', (err) => {
23
+ if (err.code === 'EADDRINUSE') {
24
+ resolve({ available: false, port });
25
+ } else {
26
+ // Unexpected error — treat as unavailable
27
+ resolve({ available: false, port });
28
+ }
29
+ });
30
+
31
+ server.once('listening', () => {
32
+ // Port is free — close the test server
33
+ const addr = server.address();
34
+ const actualPort = addr ? addr.port : port;
35
+ server.close(() => {
36
+ resolve({ available: true, port: actualPort });
37
+ });
38
+ });
39
+
40
+ server.listen(port);
41
+ });
42
+ }
43
+
44
+ module.exports = { checkPort };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Port guard tests - Phase 83 Task 2
3
+ */
4
+
5
+ import { describe, it, expect, vi, afterEach } from 'vitest';
6
+ import net from 'net';
7
+
8
+ import { checkPort } from './port-guard.js';
9
+
10
+ describe('port-guard', () => {
11
+ let tempServer;
12
+
13
+ afterEach(() => {
14
+ if (tempServer) {
15
+ tempServer.close();
16
+ tempServer = null;
17
+ }
18
+ });
19
+
20
+ it('returns available:true when port is free', async () => {
21
+ // Use a high ephemeral port unlikely to be in use
22
+ const result = await checkPort(0);
23
+ expect(result.available).toBe(true);
24
+ });
25
+
26
+ it('returns available:false when port is occupied', async () => {
27
+ // Occupy a port first
28
+ tempServer = net.createServer();
29
+ await new Promise((resolve, reject) => {
30
+ tempServer.listen(0, resolve);
31
+ tempServer.on('error', reject);
32
+ });
33
+ const port = tempServer.address().port;
34
+
35
+ const result = await checkPort(port);
36
+ expect(result.available).toBe(false);
37
+ });
38
+
39
+ it('includes pid info when port is occupied (best effort)', async () => {
40
+ tempServer = net.createServer();
41
+ await new Promise((resolve, reject) => {
42
+ tempServer.listen(0, resolve);
43
+ tempServer.on('error', reject);
44
+ });
45
+ const port = tempServer.address().port;
46
+
47
+ const result = await checkPort(port);
48
+ expect(result.available).toBe(false);
49
+ // pid is best-effort (may not be available on all platforms)
50
+ expect(result).toHaveProperty('port', port);
51
+ });
52
+
53
+ it('handles EADDRINUSE gracefully', async () => {
54
+ tempServer = net.createServer();
55
+ await new Promise((resolve, reject) => {
56
+ tempServer.listen(0, resolve);
57
+ tempServer.on('error', reject);
58
+ });
59
+ const port = tempServer.address().port;
60
+
61
+ // Should not throw
62
+ const result = await checkPort(port);
63
+ expect(result.available).toBe(false);
64
+ });
65
+ });
@@ -109,9 +109,11 @@ function readProjectMetadata(projectDir) {
109
109
  const hasTlc = fs.existsSync(path.join(projectDir, '.tlc.json'));
110
110
  const hasPlanning = fs.existsSync(path.join(projectDir, '.planning'));
111
111
 
112
- // Read name and version from package.json if present
112
+ // Read name, version, and workspaces from package.json if present
113
113
  let name = path.basename(projectDir);
114
114
  let version = null;
115
+ let isMonorepo = false;
116
+ let workspaces = [];
115
117
 
116
118
  const pkgPath = path.join(projectDir, 'package.json');
117
119
  if (fs.existsSync(pkgPath)) {
@@ -123,6 +125,34 @@ function readProjectMetadata(projectDir) {
123
125
  if (pkg.version) {
124
126
  version = pkg.version;
125
127
  }
128
+
129
+ // Detect monorepo workspaces (npm array or yarn object format)
130
+ let workspacePatterns = null;
131
+ if (Array.isArray(pkg.workspaces)) {
132
+ workspacePatterns = pkg.workspaces;
133
+ } else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages)) {
134
+ workspacePatterns = pkg.workspaces.packages;
135
+ }
136
+
137
+ if (workspacePatterns) {
138
+ isMonorepo = true;
139
+ // Resolve glob patterns to actual directories
140
+ for (const pattern of workspacePatterns) {
141
+ try {
142
+ const globDir = path.join(projectDir, path.dirname(pattern));
143
+ if (fs.existsSync(globDir)) {
144
+ const entries = fs.readdirSync(globDir, { withFileTypes: true });
145
+ for (const entry of entries) {
146
+ if (entry.isDirectory()) {
147
+ workspaces.push(path.join(path.dirname(pattern), entry.name));
148
+ }
149
+ }
150
+ }
151
+ } catch {
152
+ // Ignore glob resolution errors
153
+ }
154
+ }
155
+ }
126
156
  } catch {
127
157
  // Ignore malformed package.json
128
158
  }
@@ -147,6 +177,8 @@ function readProjectMetadata(projectDir) {
147
177
  phaseName: phaseInfo.phaseName,
148
178
  totalPhases: phaseInfo.totalPhases,
149
179
  completedPhases: phaseInfo.completedPhases,
180
+ isMonorepo,
181
+ workspaces,
150
182
  };
151
183
  }
152
184
 
@@ -235,9 +267,12 @@ class ProjectScanner {
235
267
  if (typeof onProgress === 'function') {
236
268
  onProgress(projectsByPath.size);
237
269
  }
270
+
271
+ // Stop recursion: a project's children are not separate projects
272
+ return;
238
273
  }
239
274
 
240
- // Recurse into subdirectories
275
+ // Recurse into subdirectories (only for non-project directories)
241
276
  let entries;
242
277
  try {
243
278
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -386,4 +386,156 @@ describe('ProjectScanner', () => {
386
386
  // The last reported count should match total found
387
387
  expect(progressCounts[progressCounts.length - 1]).toBe(2);
388
388
  });
389
+
390
+ // =========================================================================
391
+ // Phase 79 — Task 1: Stop recursion at project boundaries
392
+ // =========================================================================
393
+
394
+ // Test 20: Does NOT recurse into subdirectories of a detected project
395
+ it('does not recurse into subdirectories of a detected project', () => {
396
+ // Create a TLC project with a nested sub-package that also looks like a project
397
+ const projectDir = createTlcProject(tempDir, 'monorepo-project');
398
+ const subPkgDir = path.join(projectDir, 'packages', 'sub-package');
399
+ fs.mkdirSync(subPkgDir, { recursive: true });
400
+ fs.writeFileSync(path.join(subPkgDir, 'package.json'), JSON.stringify({ name: 'sub-package' }));
401
+ fs.mkdirSync(path.join(subPkgDir, '.git'), { recursive: true });
402
+
403
+ const results = scanner.scan([tempDir]);
404
+
405
+ // Should only find the parent project, not the sub-package
406
+ expect(results).toHaveLength(1);
407
+ expect(results[0].name).toBe('monorepo-project');
408
+ });
409
+
410
+ // Test 21: TLC project's server/ subdirectory not listed separately
411
+ it('does not list subdirectories of a TLC project as separate projects', () => {
412
+ const projectDir = createTlcProject(tempDir, 'tlc-project');
413
+ // Create a server/ subdirectory with its own package.json + .git
414
+ const serverDir = path.join(projectDir, 'server');
415
+ fs.mkdirSync(serverDir, { recursive: true });
416
+ fs.writeFileSync(path.join(serverDir, 'package.json'), JSON.stringify({ name: 'tlc-server' }));
417
+ fs.mkdirSync(path.join(serverDir, '.git'), { recursive: true });
418
+
419
+ const results = scanner.scan([tempDir]);
420
+
421
+ expect(results).toHaveLength(1);
422
+ expect(results[0].name).toBe('tlc-project');
423
+ });
424
+
425
+ // Test 22: Top-level non-project directories are still traversed
426
+ it('still traverses non-project directories to find nested projects', () => {
427
+ // Create a plain directory (not a project) with a project nested inside
428
+ const groupDir = path.join(tempDir, 'my-workspace');
429
+ fs.mkdirSync(groupDir, { recursive: true });
430
+ // No .tlc.json, no .planning, no package.json+.git — just a folder
431
+ createTlcProject(groupDir, 'nested-real-project');
432
+
433
+ const results = scanner.scan([tempDir]);
434
+
435
+ expect(results).toHaveLength(1);
436
+ expect(results[0].name).toBe('nested-real-project');
437
+ });
438
+
439
+ // Test 23: Multiple projects at same level, none recurse into children
440
+ it('finds sibling projects but does not recurse into either', () => {
441
+ const projA = createTlcProject(tempDir, 'project-a');
442
+ const projB = createTlcProject(tempDir, 'project-b');
443
+
444
+ // Add nested sub-projects inside each
445
+ const nestedA = path.join(projA, 'nested');
446
+ fs.mkdirSync(nestedA, { recursive: true });
447
+ fs.writeFileSync(path.join(nestedA, '.tlc.json'), '{}');
448
+
449
+ const nestedB = path.join(projB, 'apps', 'frontend');
450
+ fs.mkdirSync(nestedB, { recursive: true });
451
+ fs.writeFileSync(path.join(nestedB, 'package.json'), JSON.stringify({ name: 'frontend' }));
452
+ fs.mkdirSync(path.join(nestedB, '.git'), { recursive: true });
453
+
454
+ const results = scanner.scan([tempDir]);
455
+
456
+ expect(results).toHaveLength(2);
457
+ const names = results.map(r => r.name);
458
+ expect(names).toContain('project-a');
459
+ expect(names).toContain('project-b');
460
+ });
461
+
462
+ // =========================================================================
463
+ // Phase 79 — Task 2: Monorepo sub-package metadata
464
+ // =========================================================================
465
+
466
+ // Test 24: Detects npm workspaces array format
467
+ it('detects npm workspaces and returns isMonorepo: true', () => {
468
+ createTlcProject(tempDir, 'npm-monorepo', {
469
+ packageJson: {
470
+ name: 'npm-monorepo',
471
+ version: '1.0.0',
472
+ workspaces: ['packages/*'],
473
+ },
474
+ });
475
+
476
+ // Create a matching sub-package directory
477
+ const pkgDir = path.join(tempDir, 'npm-monorepo', 'packages', 'core');
478
+ fs.mkdirSync(pkgDir, { recursive: true });
479
+ fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@mono/core' }));
480
+
481
+ const results = scanner.scan([tempDir]);
482
+
483
+ expect(results).toHaveLength(1);
484
+ expect(results[0].isMonorepo).toBe(true);
485
+ expect(results[0].workspaces).toBeInstanceOf(Array);
486
+ expect(results[0].workspaces.length).toBeGreaterThanOrEqual(1);
487
+ });
488
+
489
+ // Test 25: Detects yarn workspaces object format
490
+ it('detects yarn workspaces object format', () => {
491
+ createTlcProject(tempDir, 'yarn-monorepo', {
492
+ packageJson: {
493
+ name: 'yarn-monorepo',
494
+ version: '1.0.0',
495
+ workspaces: { packages: ['packages/*'] },
496
+ },
497
+ });
498
+
499
+ const pkgDir = path.join(tempDir, 'yarn-monorepo', 'packages', 'utils');
500
+ fs.mkdirSync(pkgDir, { recursive: true });
501
+ fs.writeFileSync(path.join(pkgDir, 'package.json'), JSON.stringify({ name: '@mono/utils' }));
502
+
503
+ const results = scanner.scan([tempDir]);
504
+
505
+ expect(results).toHaveLength(1);
506
+ expect(results[0].isMonorepo).toBe(true);
507
+ expect(results[0].workspaces).toBeInstanceOf(Array);
508
+ expect(results[0].workspaces.length).toBeGreaterThanOrEqual(1);
509
+ });
510
+
511
+ // Test 26: Non-monorepo returns isMonorepo: false and empty workspaces
512
+ it('returns isMonorepo false and empty workspaces for regular project', () => {
513
+ createTlcProject(tempDir, 'regular-project', {
514
+ packageJson: { name: 'regular-project', version: '1.0.0' },
515
+ });
516
+
517
+ const results = scanner.scan([tempDir]);
518
+
519
+ expect(results).toHaveLength(1);
520
+ expect(results[0].isMonorepo).toBe(false);
521
+ expect(results[0].workspaces).toEqual([]);
522
+ });
523
+
524
+ // Test 27: Monorepo with no matching workspace directories
525
+ it('returns empty workspaces when glob pattern matches nothing', () => {
526
+ createTlcProject(tempDir, 'empty-mono', {
527
+ packageJson: {
528
+ name: 'empty-mono',
529
+ version: '1.0.0',
530
+ workspaces: ['packages/*'],
531
+ },
532
+ });
533
+ // Don't create the packages/ directory at all
534
+
535
+ const results = scanner.scan([tempDir]);
536
+
537
+ expect(results).toHaveLength(1);
538
+ expect(results[0].isMonorepo).toBe(true);
539
+ expect(results[0].workspaces).toEqual([]);
540
+ });
389
541
  });