tlc-claude-code 2.0.1 → 2.2.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 (109) hide show
  1. package/.claude/agents/builder.md +144 -0
  2. package/.claude/agents/planner.md +143 -0
  3. package/.claude/agents/reviewer.md +160 -0
  4. package/.claude/commands/tlc/build.md +4 -0
  5. package/.claude/commands/tlc/deploy.md +194 -2
  6. package/.claude/commands/tlc/e2e-verify.md +214 -0
  7. package/.claude/commands/tlc/guard.md +191 -0
  8. package/.claude/commands/tlc/help.md +32 -0
  9. package/.claude/commands/tlc/init.md +73 -37
  10. package/.claude/commands/tlc/llm.md +19 -4
  11. package/.claude/commands/tlc/preflight.md +134 -0
  12. package/.claude/commands/tlc/review-plan.md +363 -0
  13. package/.claude/commands/tlc/review.md +172 -57
  14. package/.claude/commands/tlc/watchci.md +159 -0
  15. package/.claude/hooks/tlc-block-tools.sh +41 -0
  16. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  17. package/.claude/hooks/tlc-post-build.sh +38 -0
  18. package/.claude/hooks/tlc-post-push.sh +22 -0
  19. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  20. package/.claude/hooks/tlc-session-init.sh +123 -0
  21. package/CLAUDE.md +13 -0
  22. package/bin/install.js +268 -2
  23. package/bin/postinstall.js +102 -24
  24. package/bin/setup-autoupdate.js +206 -0
  25. package/bin/setup-autoupdate.test.js +124 -0
  26. package/bin/tlc.js +0 -0
  27. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  28. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  29. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  30. package/dashboard-web/dist/index.html +2 -2
  31. package/docker-compose.dev.yml +18 -12
  32. package/package.json +4 -2
  33. package/scripts/project-docs.js +1 -1
  34. package/server/index.js +228 -2
  35. package/server/lib/capture-bridge.js +242 -0
  36. package/server/lib/capture-bridge.test.js +363 -0
  37. package/server/lib/capture-guard.js +140 -0
  38. package/server/lib/capture-guard.test.js +182 -0
  39. package/server/lib/command-runner.js +159 -0
  40. package/server/lib/command-runner.test.js +92 -0
  41. package/server/lib/cost-tracker.test.js +49 -12
  42. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  43. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  44. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  45. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  46. package/server/lib/deploy/security-gates.js +11 -24
  47. package/server/lib/deploy/security-gates.test.js +9 -2
  48. package/server/lib/deploy-engine.js +182 -0
  49. package/server/lib/deploy-engine.test.js +147 -0
  50. package/server/lib/docker-api.js +137 -0
  51. package/server/lib/docker-api.test.js +202 -0
  52. package/server/lib/docker-client.js +297 -0
  53. package/server/lib/docker-client.test.js +308 -0
  54. package/server/lib/input-sanitizer.js +86 -0
  55. package/server/lib/input-sanitizer.test.js +117 -0
  56. package/server/lib/launchd-agent.js +225 -0
  57. package/server/lib/launchd-agent.test.js +185 -0
  58. package/server/lib/memory-api.js +3 -1
  59. package/server/lib/memory-api.test.js +3 -5
  60. package/server/lib/memory-bridge-e2e.test.js +160 -0
  61. package/server/lib/memory-committer.js +18 -4
  62. package/server/lib/memory-committer.test.js +21 -0
  63. package/server/lib/memory-hooks-capture.test.js +69 -4
  64. package/server/lib/memory-hooks-integration.test.js +98 -0
  65. package/server/lib/memory-hooks.js +42 -4
  66. package/server/lib/memory-store-adapter.js +105 -0
  67. package/server/lib/memory-store-adapter.test.js +141 -0
  68. package/server/lib/memory-wiring-e2e.test.js +93 -0
  69. package/server/lib/nginx-config.js +114 -0
  70. package/server/lib/nginx-config.test.js +82 -0
  71. package/server/lib/ollama-health.js +91 -0
  72. package/server/lib/ollama-health.test.js +74 -0
  73. package/server/lib/orchestration/agent-dispatcher.js +114 -0
  74. package/server/lib/orchestration/agent-dispatcher.test.js +110 -0
  75. package/server/lib/orchestration/orchestrator.js +130 -0
  76. package/server/lib/orchestration/orchestrator.test.js +192 -0
  77. package/server/lib/orchestration/tmux-manager.js +101 -0
  78. package/server/lib/orchestration/tmux-manager.test.js +109 -0
  79. package/server/lib/orchestration/worktree-manager.js +132 -0
  80. package/server/lib/orchestration/worktree-manager.test.js +129 -0
  81. package/server/lib/port-guard.js +44 -0
  82. package/server/lib/port-guard.test.js +65 -0
  83. package/server/lib/project-scanner.js +37 -2
  84. package/server/lib/project-scanner.test.js +152 -0
  85. package/server/lib/remember-command.js +2 -0
  86. package/server/lib/remember-command.test.js +23 -0
  87. package/server/lib/review/plan-reviewer.js +260 -0
  88. package/server/lib/review/plan-reviewer.test.js +269 -0
  89. package/server/lib/review/review-schemas.js +173 -0
  90. package/server/lib/review/review-schemas.test.js +152 -0
  91. package/server/lib/security/crypto-utils.test.js +2 -2
  92. package/server/lib/semantic-recall.js +1 -1
  93. package/server/lib/semantic-recall.test.js +17 -0
  94. package/server/lib/ssh-client.js +184 -0
  95. package/server/lib/ssh-client.test.js +127 -0
  96. package/server/lib/vps-api.js +184 -0
  97. package/server/lib/vps-api.test.js +208 -0
  98. package/server/lib/vps-bootstrap.js +124 -0
  99. package/server/lib/vps-bootstrap.test.js +79 -0
  100. package/server/lib/vps-monitor.js +126 -0
  101. package/server/lib/vps-monitor.test.js +98 -0
  102. package/server/lib/workspace-api.js +182 -1
  103. package/server/lib/workspace-api.test.js +474 -0
  104. package/server/package-lock.json +737 -0
  105. package/server/package.json +3 -0
  106. package/server/setup.sh +271 -271
  107. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  108. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  109. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ createSession,
4
+ addPane,
5
+ capturePane,
6
+ killSession,
7
+ isAvailable,
8
+ buildLayout,
9
+ } from './tmux-manager.js';
10
+
11
+ describe('tmux-manager', () => {
12
+ let exec;
13
+
14
+ beforeEach(() => {
15
+ exec = vi.fn(() => '');
16
+ });
17
+
18
+ describe('isAvailable', () => {
19
+ it('returns true when tmux is installed', () => {
20
+ exec.mockReturnValue('/usr/bin/tmux');
21
+ expect(isAvailable({ exec })).toBe(true);
22
+ });
23
+
24
+ it('returns false when tmux is missing', () => {
25
+ exec.mockImplementation(() => { throw new Error('not found'); });
26
+ expect(isAvailable({ exec })).toBe(false);
27
+ });
28
+ });
29
+
30
+ describe('createSession', () => {
31
+ it('generates correct tmux new-session command', () => {
32
+ exec.mockReturnValue('');
33
+ createSession('tlc-build-42', { exec });
34
+ const cmd = exec.mock.calls[0][0];
35
+ expect(cmd).toContain('tmux new-session');
36
+ expect(cmd).toContain('tlc-build-42');
37
+ });
38
+
39
+ it('sanitizes session names', () => {
40
+ exec.mockReturnValue('');
41
+ createSession('bad session/name!', { exec });
42
+ const cmd = exec.mock.calls[0][0];
43
+ expect(cmd).not.toContain('/');
44
+ expect(cmd).not.toContain('!');
45
+ });
46
+ });
47
+
48
+ describe('addPane', () => {
49
+ it('splits correctly for 2 panes (vertical)', () => {
50
+ exec.mockReturnValue('');
51
+ addPane('sess', 'agent-1', 'echo hello', { exec, paneCount: 2 });
52
+ const cmd = exec.mock.calls[0][0];
53
+ expect(cmd).toContain('split-window');
54
+ });
55
+
56
+ it('splits correctly for 4 panes (2x2 grid)', () => {
57
+ exec.mockReturnValue('');
58
+ addPane('sess', 'agent-3', 'echo test', { exec, paneCount: 4, paneIndex: 3 });
59
+ const cmd = exec.mock.calls[0][0];
60
+ expect(cmd).toContain('split-window');
61
+ });
62
+
63
+ it('runs command in the new pane', () => {
64
+ exec.mockReturnValue('');
65
+ addPane('sess', 'agent-1', 'codex exec "do stuff"', { exec, paneCount: 2 });
66
+ const cmds = exec.mock.calls.map(c => c[0]);
67
+ expect(cmds.some(c => c.includes('codex exec'))).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe('capturePane', () => {
72
+ it('returns pane content via tmux capture-pane', () => {
73
+ exec.mockReturnValue('Line 1\nLine 2\nLine 3');
74
+ const content = capturePane('sess', 0, { exec });
75
+ expect(content).toContain('Line 1');
76
+ expect(content).toContain('Line 3');
77
+ const cmd = exec.mock.calls[0][0];
78
+ expect(cmd).toContain('capture-pane');
79
+ expect(cmd).toContain(':.'); // pane target uses .N syntax
80
+ });
81
+ });
82
+
83
+ describe('killSession', () => {
84
+ it('runs tmux kill-session', () => {
85
+ exec.mockReturnValue('');
86
+ killSession('tlc-build-42', { exec });
87
+ const cmd = exec.mock.calls[0][0];
88
+ expect(cmd).toContain('kill-session');
89
+ expect(cmd).toContain('tlc-build-42');
90
+ });
91
+ });
92
+
93
+ describe('buildLayout', () => {
94
+ it('returns vertical for 2 agents', () => {
95
+ const layout = buildLayout(2);
96
+ expect(layout.type).toBe('vertical');
97
+ });
98
+
99
+ it('returns grid for 4 agents', () => {
100
+ const layout = buildLayout(4);
101
+ expect(layout.type).toBe('grid');
102
+ });
103
+
104
+ it('returns tiled for >4 agents', () => {
105
+ const layout = buildLayout(6);
106
+ expect(layout.type).toBe('tiled');
107
+ });
108
+ });
109
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Worktree Manager
3
+ * Creates and manages git worktrees for parallel agent work.
4
+ */
5
+
6
+ /**
7
+ * Sanitize a worktree name: spaces become hyphens, all other non-alphanumeric
8
+ * non-hyphen characters are stripped, then multiple hyphens are collapsed.
9
+ * @param {string} name
10
+ * @returns {string}
11
+ */
12
+ export function sanitizeName(name) {
13
+ return name
14
+ .replace(/ /g, '-')
15
+ .replace(/[^a-zA-Z0-9-]/g, '')
16
+ .replace(/-{2,}/g, '-');
17
+ }
18
+
19
+ /**
20
+ * Create a git worktree for the given name.
21
+ * @param {string} name - Worktree name (will be sanitized)
22
+ * @param {{ exec: Function, maxConcurrent?: number }} options
23
+ * @returns {{ name: string, branch: string, path: string }}
24
+ */
25
+ export function createWorktree(name, { exec, maxConcurrent } = {}) {
26
+ const safe = sanitizeName(name);
27
+ const limit = maxConcurrent != null ? maxConcurrent : 4;
28
+
29
+ // Only check the concurrent limit when explicitly requested (maxConcurrent passed)
30
+ if (maxConcurrent != null) {
31
+ const listOutput = exec('git worktree list');
32
+ // Exclude the primary checkout (first line) — only count auxiliary worktrees
33
+ const currentCount = listOutput
34
+ .split('\n')
35
+ .filter((line) => line.trim() !== '').length - 1;
36
+
37
+ if (currentCount >= limit) {
38
+ throw new Error(`Max concurrent worktrees (${limit}) reached`);
39
+ }
40
+ }
41
+
42
+ const branch = `worktree-${safe}`;
43
+ const path = `.claude/worktrees/${safe}`;
44
+
45
+ exec(`git worktree add -b ${branch} ${path}`);
46
+
47
+ return { name: safe, branch, path };
48
+ }
49
+
50
+ /**
51
+ * List active worktrees under .claude/worktrees/.
52
+ * @param {{ exec: Function }} options
53
+ * @returns {Array<{ name: string, branch: string, path: string }>}
54
+ */
55
+ export function listWorktrees({ exec } = {}) {
56
+ const output = exec('git worktree list');
57
+ return output
58
+ .split('\n')
59
+ .filter((line) => line.includes('.claude/worktrees/'))
60
+ .map((line) => {
61
+ // Format: /repo/.claude/worktrees/task-1 abc1234 [worktree-task-1]
62
+ const parts = line.trim().split(/\s+/);
63
+ const worktreePath = parts[0];
64
+ const branchMatch = line.match(/\[([^\]]+)\]/);
65
+ const branch = branchMatch ? branchMatch[1] : '';
66
+ const namePart = worktreePath.split('.claude/worktrees/')[1];
67
+ return { name: namePart, branch, path: worktreePath };
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Merge a worktree branch into targetBranch, then remove the worktree.
73
+ * On conflict, returns { merged: false, conflict: true } without removing.
74
+ * @param {string} name - Worktree name (already sanitized)
75
+ * @param {string} targetBranch
76
+ * @param {{ exec: Function }} options
77
+ * @returns {{ merged: boolean, conflict?: boolean }}
78
+ */
79
+ export function mergeWorktree(name, targetBranch, { exec } = {}) {
80
+ const safe = sanitizeName(name);
81
+ const branch = `worktree-${safe}`;
82
+ const path = `.claude/worktrees/${safe}`;
83
+
84
+ try {
85
+ exec(`git checkout ${targetBranch}`);
86
+ exec(`git merge ${branch}`);
87
+ } catch (_err) {
88
+ return { merged: false, conflict: true };
89
+ }
90
+
91
+ exec(`git worktree remove ${path}`);
92
+ exec(`git branch -D ${branch}`);
93
+
94
+ return { merged: true };
95
+ }
96
+
97
+ /**
98
+ * Forcefully remove a worktree directory and its tracking branch.
99
+ * @param {string} name - Worktree name (already sanitized)
100
+ * @param {{ exec: Function }} options
101
+ */
102
+ export function removeWorktree(name, { exec } = {}) {
103
+ const safe = sanitizeName(name);
104
+ const branch = `worktree-${safe}`;
105
+ const path = `.claude/worktrees/${safe}`;
106
+
107
+ exec(`git worktree remove --force ${path}`);
108
+ exec(`git branch -D ${branch}`);
109
+ }
110
+
111
+ /**
112
+ * Remove worktrees whose last commit is older than maxAge milliseconds.
113
+ * @param {{ exec: Function, maxAge?: number }} options
114
+ * @returns {string[]} Names of removed worktrees
115
+ */
116
+ export function cleanupStale({ exec, maxAge = 2 * 3600_000 } = {}) {
117
+ const worktrees = listWorktrees({ exec });
118
+ const removed = [];
119
+ const now = Date.now();
120
+
121
+ for (const wt of worktrees) {
122
+ const lastCommitStr = exec(`git log -1 --format=%cI ${wt.branch}`).trim();
123
+ const lastCommitTime = new Date(lastCommitStr).getTime();
124
+
125
+ if (now - lastCommitTime > maxAge) {
126
+ removeWorktree(wt.name, { exec });
127
+ removed.push(wt.name);
128
+ }
129
+ }
130
+
131
+ return removed;
132
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import {
3
+ createWorktree,
4
+ listWorktrees,
5
+ mergeWorktree,
6
+ removeWorktree,
7
+ cleanupStale,
8
+ sanitizeName,
9
+ } from './worktree-manager.js';
10
+
11
+ describe('worktree-manager', () => {
12
+ let exec;
13
+
14
+ beforeEach(() => {
15
+ exec = vi.fn(() => '');
16
+ });
17
+
18
+ describe('sanitizeName', () => {
19
+ it('passes through alphanumeric and hyphens', () => {
20
+ expect(sanitizeName('phase-42-task-1')).toBe('phase-42-task-1');
21
+ });
22
+
23
+ it('strips special characters', () => {
24
+ expect(sanitizeName('my task/name@v2!')).toBe('my-tasknamev2');
25
+ });
26
+
27
+ it('collapses multiple hyphens', () => {
28
+ expect(sanitizeName('a--b---c')).toBe('a-b-c');
29
+ });
30
+ });
31
+
32
+ describe('createWorktree', () => {
33
+ it('creates directory and branch', () => {
34
+ exec.mockReturnValue('');
35
+ const result = createWorktree('phase-42-task-1', { exec });
36
+ expect(result.name).toBe('phase-42-task-1');
37
+ expect(result.branch).toBe('worktree-phase-42-task-1');
38
+ expect(result.path).toContain('.claude/worktrees/phase-42-task-1');
39
+ expect(exec).toHaveBeenCalled();
40
+ const cmd = exec.mock.calls[0][0];
41
+ expect(cmd).toContain('git worktree add');
42
+ });
43
+
44
+ it('rejects when at max concurrent limit', () => {
45
+ // 5 lines = 1 primary + 4 auxiliary worktrees → at limit of 4
46
+ exec.mockReturnValue('/repo abc [main]\nworktree-a\nworktree-b\nworktree-c\nworktree-d\n');
47
+ expect(() => createWorktree('task-5', { exec, maxConcurrent: 4 }))
48
+ .toThrow(/max/i);
49
+ });
50
+
51
+ it('sanitizes names', () => {
52
+ exec.mockReturnValue('');
53
+ const result = createWorktree('bad name/here!', { exec });
54
+ expect(result.name).toBe('bad-namehere');
55
+ });
56
+ });
57
+
58
+ describe('listWorktrees', () => {
59
+ it('returns active worktrees with branch and path', () => {
60
+ exec.mockReturnValue(
61
+ '/repo/.claude/worktrees/task-1 abc1234 [worktree-task-1]\n' +
62
+ '/repo/.claude/worktrees/task-2 def5678 [worktree-task-2]\n'
63
+ );
64
+ const list = listWorktrees({ exec });
65
+ expect(list).toHaveLength(2);
66
+ expect(list[0].name).toBe('task-1');
67
+ expect(list[0].branch).toBe('worktree-task-1');
68
+ expect(list[0].path).toContain('task-1');
69
+ });
70
+
71
+ it('returns empty array when no worktrees', () => {
72
+ exec.mockReturnValue('/repo abc1234 [main]\n');
73
+ const list = listWorktrees({ exec });
74
+ expect(list).toHaveLength(0);
75
+ });
76
+ });
77
+
78
+ describe('mergeWorktree', () => {
79
+ it('merges branch and cleans up', () => {
80
+ exec.mockReturnValue('');
81
+ const result = mergeWorktree('task-1', 'main', { exec });
82
+ expect(result.merged).toBe(true);
83
+ const cmds = exec.mock.calls.map(c => c[0]);
84
+ expect(cmds.some(c => c.includes('git merge'))).toBe(true);
85
+ expect(cmds.some(c => c.includes('git worktree remove'))).toBe(true);
86
+ });
87
+
88
+ it('reports conflict without destroying worktree', () => {
89
+ exec.mockImplementation((cmd) => {
90
+ if (cmd.includes('git merge')) throw new Error('CONFLICT');
91
+ return '';
92
+ });
93
+ const result = mergeWorktree('task-1', 'main', { exec });
94
+ expect(result.merged).toBe(false);
95
+ expect(result.conflict).toBe(true);
96
+ // Should NOT have called worktree remove
97
+ const cmds = exec.mock.calls.map(c => c[0]);
98
+ expect(cmds.some(c => c.includes('git worktree remove'))).toBe(false);
99
+ });
100
+ });
101
+
102
+ describe('removeWorktree', () => {
103
+ it('removes both directory and branch', () => {
104
+ exec.mockReturnValue('');
105
+ removeWorktree('task-1', { exec });
106
+ const cmds = exec.mock.calls.map(c => c[0]);
107
+ expect(cmds.some(c => c.includes('git worktree remove'))).toBe(true);
108
+ expect(cmds.some(c => c.includes('git branch -D'))).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe('cleanupStale', () => {
113
+ it('removes only worktrees past timeout', () => {
114
+ const oldDate = new Date(Date.now() - 3 * 3600_000).toISOString(); // 3h ago
115
+ const freshDate = new Date(Date.now() - 30 * 60_000).toISOString(); // 30min ago
116
+ exec.mockImplementation((cmd) => {
117
+ if (cmd.includes('worktree list')) {
118
+ return '/repo/.claude/worktrees/old-task abc [worktree-old-task]\n/repo/.claude/worktrees/new-task def [worktree-new-task]\n';
119
+ }
120
+ if (cmd.includes('log') && cmd.includes('old-task')) return oldDate;
121
+ if (cmd.includes('log') && cmd.includes('new-task')) return freshDate;
122
+ return '';
123
+ });
124
+ const removed = cleanupStale({ exec, maxAge: 2 * 3600_000 });
125
+ expect(removed).toContain('old-task');
126
+ expect(removed).not.toContain('new-task');
127
+ });
128
+ });
129
+ });
@@ -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 });