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,129 @@
1
+ /**
2
+ * Workspace Context Builder
3
+ *
4
+ * Combines workspace detection, memory inheritance, and CLAUDE.md cascade
5
+ * into a unified context object that TLC commands can consume.
6
+ *
7
+ * Factory function `createWorkspaceContext` accepts three injected dependencies:
8
+ * - workspaceDetector — detects if a project is in a workspace
9
+ * - memoryInheritance — loads inherited decisions/gotchas from workspace
10
+ * - claudeCascade — provides cascaded CLAUDE.md content
11
+ *
12
+ * The returned object exposes:
13
+ * - buildContext(projectDir) — builds the full workspace context
14
+ *
15
+ * @module workspace-context
16
+ */
17
+
18
+ /**
19
+ * Static token budget split between project and workspace context.
20
+ * @type {{ project: number, workspace: number }}
21
+ */
22
+ const TOKEN_BUDGET = { project: 0.6, workspace: 0.4 };
23
+
24
+ /**
25
+ * Format an array of memory items into a markdown list.
26
+ *
27
+ * @param {string} heading - Section heading (e.g. "Decisions")
28
+ * @param {Object[]} items - Memory items with topic and text properties
29
+ * @returns {string} Formatted markdown section, or empty string if no items
30
+ */
31
+ function formatMemorySection(heading, items) {
32
+ if (!items || items.length === 0) return '';
33
+
34
+ const lines = [`### ${heading}`, ''];
35
+ for (const item of items) {
36
+ const label = item.topic || 'unknown';
37
+ const text = item.text || '';
38
+ lines.push(`- **${label}**: ${text}`);
39
+ }
40
+ return lines.join('\n');
41
+ }
42
+
43
+ /**
44
+ * Create a workspace context builder instance.
45
+ *
46
+ * @param {Object} deps
47
+ * @param {Object} deps.workspaceDetector - Detector with detectWorkspace(dir)
48
+ * @param {Object} deps.memoryInheritance - Engine with loadInheritedMemory(dir)
49
+ * @param {Object} deps.claudeCascade - Cascade with getCascadedContext(dir)
50
+ * @returns {{ buildContext: (projectDir: string) => Promise<WorkspaceContext> }}
51
+ *
52
+ * @typedef {Object} WorkspaceContext
53
+ * @property {boolean} isInWorkspace - Whether the project is inside a workspace
54
+ * @property {string|null} workspaceSection - Markdown section for workspace context
55
+ * @property {Object[]} inheritedDecisions - Decisions inherited from workspace
56
+ * @property {Object[]} inheritedGotchas - Gotchas inherited from workspace
57
+ * @property {{ project: number, workspace: number }} tokenBudget - Token budget split
58
+ */
59
+ export function createWorkspaceContext({ workspaceDetector, memoryInheritance, claudeCascade }) {
60
+ /**
61
+ * Build full workspace context for a project directory.
62
+ *
63
+ * Loads inherited memory, detects workspace status, and merges cascaded
64
+ * CLAUDE.md content into a single context object.
65
+ *
66
+ * @param {string} projectDir - Absolute path to the project directory
67
+ * @returns {Promise<WorkspaceContext>}
68
+ */
69
+ async function buildContext(projectDir) {
70
+ // Detect workspace
71
+ const wsResult = workspaceDetector.detectWorkspace(projectDir);
72
+ const isInWorkspace = wsResult.isInWorkspace;
73
+
74
+ // Load inherited memory
75
+ const memory = await memoryInheritance.loadInheritedMemory(projectDir);
76
+
77
+ const inheritedDecisions = memory.decisions || [];
78
+ const inheritedGotchas = memory.gotchas || [];
79
+
80
+ // For standalone projects, return minimal context
81
+ if (!isInWorkspace) {
82
+ return {
83
+ isInWorkspace: false,
84
+ workspaceSection: null,
85
+ inheritedDecisions,
86
+ inheritedGotchas,
87
+ tokenBudget: TOKEN_BUDGET,
88
+ };
89
+ }
90
+
91
+ // Get cascaded CLAUDE.md content
92
+ const cascadeResult = await claudeCascade.getCascadedContext(projectDir);
93
+
94
+ // Build workspace section as markdown
95
+ const sectionParts = ['## Workspace Context', ''];
96
+
97
+ // Include cascaded CLAUDE.md content
98
+ if (cascadeResult.workspaceContent) {
99
+ sectionParts.push(cascadeResult.workspaceContent.trim());
100
+ sectionParts.push('');
101
+ }
102
+
103
+ // Include inherited decisions
104
+ const decisionsSection = formatMemorySection('Inherited Decisions', inheritedDecisions);
105
+ if (decisionsSection) {
106
+ sectionParts.push(decisionsSection);
107
+ sectionParts.push('');
108
+ }
109
+
110
+ // Include inherited gotchas
111
+ const gotchasSection = formatMemorySection('Inherited Gotchas', inheritedGotchas);
112
+ if (gotchasSection) {
113
+ sectionParts.push(gotchasSection);
114
+ sectionParts.push('');
115
+ }
116
+
117
+ const workspaceSection = sectionParts.join('\n').trimEnd();
118
+
119
+ return {
120
+ isInWorkspace: true,
121
+ workspaceSection,
122
+ inheritedDecisions,
123
+ inheritedGotchas,
124
+ tokenBudget: TOKEN_BUDGET,
125
+ };
126
+ }
127
+
128
+ return { buildContext };
129
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Workspace Context Tests
3
+ *
4
+ * Tests for building workspace context that TLC commands can consume.
5
+ * Combines workspace detection, memory inheritance, and CLAUDE.md cascade
6
+ * into a unified context object.
7
+ *
8
+ * Dependencies are injected (workspaceDetector, memoryInheritance, claudeCascade)
9
+ * and fully mocked with vi.fn().
10
+ *
11
+ * These tests are written BEFORE the implementation (Red phase).
12
+ */
13
+
14
+ import { describe, it, beforeEach, expect, vi } from 'vitest';
15
+ import { createWorkspaceContext } from './workspace-context.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Mock factories
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Creates a mock workspaceDetector that reports being in a workspace.
23
+ * @param {boolean} isInWorkspace - Whether to simulate being in a workspace
24
+ * @returns {object} Mock detector with detectWorkspace stub
25
+ */
26
+ function createMockDetector(isInWorkspace = true) {
27
+ return {
28
+ detectWorkspace: vi.fn().mockReturnValue({
29
+ isInWorkspace,
30
+ workspaceRoot: isInWorkspace ? '/workspace' : null,
31
+ projectPath: '/workspace/my-project',
32
+ relativeProjectPath: isInWorkspace ? 'my-project' : null,
33
+ }),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * Creates a mock memoryInheritance engine.
39
+ * @param {object} memory - Memory to return from loadInheritedMemory
40
+ * @returns {object} Mock engine with loadInheritedMemory stub
41
+ */
42
+ function createMockMemoryInheritance(memory = null) {
43
+ const defaultMemory = {
44
+ decisions: [
45
+ { topic: 'use-postgres', text: 'Use Postgres for JSONB support', source: 'workspace' },
46
+ { topic: 'rest-api', text: 'Use REST over GraphQL', source: 'workspace' },
47
+ ],
48
+ gotchas: [
49
+ { topic: 'auth-warmup', text: 'Auth service needs 2s warmup', source: 'workspace' },
50
+ ],
51
+ preferences: [],
52
+ conversations: [],
53
+ };
54
+
55
+ return {
56
+ loadInheritedMemory: vi.fn().mockResolvedValue(memory || defaultMemory),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Creates a mock claudeCascade.
62
+ * @param {object} context - Context to return from getCascadedContext
63
+ * @returns {object} Mock cascade with getCascadedContext stub
64
+ */
65
+ function createMockCascade(context = null) {
66
+ const defaultContext = {
67
+ workspaceContent: '# Workspace\n\n## Coding Standards\n\nUse ESLint.\n',
68
+ projectContent: '# Project\n\nProject rules.\n',
69
+ merged: '<!-- TLC-WORKSPACE-START -->\n## Coding Standards\n\nUse ESLint.\n<!-- TLC-WORKSPACE-END -->\n\n# Project\n\nProject rules.\n',
70
+ };
71
+
72
+ return {
73
+ getCascadedContext: vi.fn().mockResolvedValue(context || defaultContext),
74
+ };
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Tests
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('workspace-context', () => {
82
+ let mockDetector;
83
+ let mockMemoryInheritance;
84
+ let mockCascade;
85
+ let wsContext;
86
+
87
+ beforeEach(() => {
88
+ mockDetector = createMockDetector(true);
89
+ mockMemoryInheritance = createMockMemoryInheritance();
90
+ mockCascade = createMockCascade();
91
+
92
+ wsContext = createWorkspaceContext({
93
+ workspaceDetector: mockDetector,
94
+ memoryInheritance: mockMemoryInheritance,
95
+ claudeCascade: mockCascade,
96
+ });
97
+ });
98
+
99
+ // -------------------------------------------------------------------------
100
+ // 1. Session start loads inherited memory
101
+ // -------------------------------------------------------------------------
102
+ it('session start loads inherited memory (calls memoryInheritance.loadInheritedMemory)', async () => {
103
+ await wsContext.buildContext('/workspace/my-project');
104
+
105
+ expect(mockMemoryInheritance.loadInheritedMemory).toHaveBeenCalledWith(
106
+ '/workspace/my-project'
107
+ );
108
+ });
109
+
110
+ // -------------------------------------------------------------------------
111
+ // 2. Context includes "Workspace Context" section heading
112
+ // -------------------------------------------------------------------------
113
+ it('context includes "Workspace Context" section heading in workspaceSection', async () => {
114
+ const context = await wsContext.buildContext('/workspace/my-project');
115
+
116
+ expect(context.workspaceSection).toContain('## Workspace Context');
117
+ });
118
+
119
+ // -------------------------------------------------------------------------
120
+ // 3. Workspace decisions included in inheritedDecisions
121
+ // -------------------------------------------------------------------------
122
+ it('workspace decisions included in inheritedDecisions', async () => {
123
+ const context = await wsContext.buildContext('/workspace/my-project');
124
+
125
+ expect(context.inheritedDecisions).toHaveLength(2);
126
+ expect(context.inheritedDecisions[0]).toHaveProperty('topic', 'use-postgres');
127
+ expect(context.inheritedDecisions[1]).toHaveProperty('topic', 'rest-api');
128
+ });
129
+
130
+ // -------------------------------------------------------------------------
131
+ // 4. Workspace gotchas surfaced in inheritedGotchas
132
+ // -------------------------------------------------------------------------
133
+ it('workspace gotchas surfaced in inheritedGotchas', async () => {
134
+ const context = await wsContext.buildContext('/workspace/my-project');
135
+
136
+ expect(context.inheritedGotchas).toHaveLength(1);
137
+ expect(context.inheritedGotchas[0]).toHaveProperty('topic', 'auth-warmup');
138
+ });
139
+
140
+ // -------------------------------------------------------------------------
141
+ // 5. Token budget split: project=0.6, workspace=0.4
142
+ // -------------------------------------------------------------------------
143
+ it('token budget split: project=0.6, workspace=0.4', async () => {
144
+ const context = await wsContext.buildContext('/workspace/my-project');
145
+
146
+ expect(context.tokenBudget).toEqual({ project: 0.6, workspace: 0.4 });
147
+ });
148
+
149
+ // -------------------------------------------------------------------------
150
+ // 6. No workspace section for standalone projects
151
+ // -------------------------------------------------------------------------
152
+ it('no workspace section for standalone projects (workspaceSection is empty/null)', async () => {
153
+ const standaloneDetector = createMockDetector(false);
154
+ const standaloneMemory = createMockMemoryInheritance({
155
+ decisions: [],
156
+ gotchas: [],
157
+ preferences: [],
158
+ conversations: [],
159
+ });
160
+ const standaloneCascade = createMockCascade({
161
+ workspaceContent: null,
162
+ projectContent: '# Project\n',
163
+ merged: '# Project\n',
164
+ });
165
+
166
+ const standalone = createWorkspaceContext({
167
+ workspaceDetector: standaloneDetector,
168
+ memoryInheritance: standaloneMemory,
169
+ claudeCascade: standaloneCascade,
170
+ });
171
+
172
+ const context = await standalone.buildContext('/standalone-project');
173
+
174
+ expect(context.isInWorkspace).toBe(false);
175
+ expect(context.workspaceSection).toBeFalsy();
176
+ });
177
+
178
+ // -------------------------------------------------------------------------
179
+ // 7. Handles missing workspace memory gracefully (empty arrays)
180
+ // -------------------------------------------------------------------------
181
+ it('handles missing workspace memory gracefully (empty arrays)', async () => {
182
+ const emptyMemory = createMockMemoryInheritance({
183
+ decisions: [],
184
+ gotchas: [],
185
+ preferences: [],
186
+ conversations: [],
187
+ });
188
+
189
+ const ctx = createWorkspaceContext({
190
+ workspaceDetector: mockDetector,
191
+ memoryInheritance: emptyMemory,
192
+ claudeCascade: mockCascade,
193
+ });
194
+
195
+ const context = await ctx.buildContext('/workspace/my-project');
196
+
197
+ expect(context.inheritedDecisions).toEqual([]);
198
+ expect(context.inheritedGotchas).toEqual([]);
199
+ expect(context.isInWorkspace).toBe(true);
200
+ });
201
+
202
+ // -------------------------------------------------------------------------
203
+ // 8. Workspace context includes cascaded CLAUDE.md content
204
+ // -------------------------------------------------------------------------
205
+ it('workspace context includes cascaded CLAUDE.md content', async () => {
206
+ const context = await wsContext.buildContext('/workspace/my-project');
207
+
208
+ expect(mockCascade.getCascadedContext).toHaveBeenCalledWith(
209
+ '/workspace/my-project'
210
+ );
211
+ // The workspaceSection should include content from the cascade
212
+ expect(context.workspaceSection).toContain('Use ESLint');
213
+ });
214
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Workspace Detector
3
+ *
4
+ * Detects when the current project is inside a TLC workspace by walking up
5
+ * the directory tree looking for workspace markers:
6
+ * - projects.json file
7
+ * - .tlc.json with "workspace": true
8
+ * - memory/ directory
9
+ *
10
+ * @module workspace-detector
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+
17
+ /**
18
+ * Check whether a directory contains any workspace marker.
19
+ *
20
+ * Markers (checked in order):
21
+ * 1. projects.json file
22
+ * 2. .tlc.json with { "workspace": true }
23
+ * 3. memory/ directory
24
+ *
25
+ * @param {string} dir - Absolute path to check
26
+ * @returns {boolean} true if the directory is a workspace root
27
+ */
28
+ function hasWorkspaceMarker(dir) {
29
+ // Marker 1: projects.json
30
+ try {
31
+ const pj = path.join(dir, 'projects.json');
32
+ if (fs.statSync(pj).isFile()) return true;
33
+ } catch { /* not found */ }
34
+
35
+ // Marker 2: .tlc.json with workspace: true
36
+ try {
37
+ const tlcPath = path.join(dir, '.tlc.json');
38
+ if (fs.statSync(tlcPath).isFile()) {
39
+ const content = fs.readFileSync(tlcPath, 'utf8');
40
+ const json = JSON.parse(content);
41
+ if (json && json.workspace === true) return true;
42
+ }
43
+ } catch { /* not found or invalid json */ }
44
+
45
+ // Marker 3: memory/ directory
46
+ try {
47
+ const memDir = path.join(dir, 'memory');
48
+ if (fs.statSync(memDir).isDirectory()) return true;
49
+ } catch { /* not found */ }
50
+
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * Create a workspace detector instance.
56
+ *
57
+ * @param {Object} [options]
58
+ * @param {string} [options.boundary] - Stop walking at this directory (exclusive).
59
+ * Defaults to os.homedir()'s parent. The detector will never walk above
60
+ * os.homedir() regardless of this setting.
61
+ * @returns {{ detectWorkspace: (projectDir: string) => WorkspaceResult }}
62
+ *
63
+ * @typedef {Object} WorkspaceResult
64
+ * @property {boolean} isInWorkspace
65
+ * @property {string|null} workspaceRoot
66
+ * @property {string} projectPath
67
+ * @property {string|null} relativeProjectPath
68
+ */
69
+ export function createWorkspaceDetector(options = {}) {
70
+ const cache = new Map();
71
+ const homeDir = os.homedir();
72
+ const boundary = options.boundary || null;
73
+
74
+ /**
75
+ * Detect whether `projectDir` lives inside a workspace.
76
+ *
77
+ * Walks from `projectDir` upward, checking each directory for workspace
78
+ * markers. Stops at the first match (nearest parent wins), or when it
79
+ * reaches the home directory, filesystem root, or the configured boundary.
80
+ *
81
+ * @param {string} projectDir - Absolute path to the project directory
82
+ * @returns {WorkspaceResult}
83
+ */
84
+ function detectWorkspace(projectDir) {
85
+ const resolved = path.resolve(projectDir);
86
+
87
+ if (cache.has(resolved)) {
88
+ return cache.get(resolved);
89
+ }
90
+
91
+ let result;
92
+
93
+ // Check the project dir itself first
94
+ if (hasWorkspaceMarker(resolved)) {
95
+ result = {
96
+ isInWorkspace: true,
97
+ workspaceRoot: resolved,
98
+ projectPath: resolved,
99
+ relativeProjectPath: '.',
100
+ };
101
+ cache.set(resolved, result);
102
+ return result;
103
+ }
104
+
105
+ // Walk upward
106
+ let current = path.dirname(resolved);
107
+
108
+ while (true) {
109
+ // Stop conditions
110
+ // 1. Hit boundary (the boundary dir itself is NOT scanned)
111
+ if (boundary && current === path.resolve(boundary)) {
112
+ break;
113
+ }
114
+
115
+ // 2. Walked above home directory
116
+ if (homeDir && !current.startsWith(homeDir) && resolved.startsWith(homeDir)) {
117
+ break;
118
+ }
119
+
120
+ // 3. Hit filesystem root (path.dirname returns itself)
121
+ if (current === path.dirname(current)) {
122
+ // Check the root itself before giving up
123
+ if (hasWorkspaceMarker(current)) {
124
+ result = {
125
+ isInWorkspace: true,
126
+ workspaceRoot: current,
127
+ projectPath: resolved,
128
+ relativeProjectPath: path.relative(current, resolved),
129
+ };
130
+ cache.set(resolved, result);
131
+ return result;
132
+ }
133
+ break;
134
+ }
135
+
136
+ if (hasWorkspaceMarker(current)) {
137
+ result = {
138
+ isInWorkspace: true,
139
+ workspaceRoot: current,
140
+ projectPath: resolved,
141
+ relativeProjectPath: path.relative(current, resolved),
142
+ };
143
+ cache.set(resolved, result);
144
+ return result;
145
+ }
146
+
147
+ current = path.dirname(current);
148
+ }
149
+
150
+ // No workspace found
151
+ result = {
152
+ isInWorkspace: false,
153
+ workspaceRoot: null,
154
+ projectPath: resolved,
155
+ relativeProjectPath: null,
156
+ };
157
+ cache.set(resolved, result);
158
+ return result;
159
+ }
160
+
161
+ return { detectWorkspace };
162
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Workspace Detector Tests
3
+ *
4
+ * Tests for detecting when a project is inside a TLC workspace.
5
+ * Uses temp directories with actual marker files.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+
13
+ /** Create a unique temp directory for each test */
14
+ function makeTmpDir() {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'ws-detect-'));
16
+ }
17
+
18
+ /** Recursively remove a directory */
19
+ function rmDir(dir) {
20
+ fs.rmSync(dir, { recursive: true, force: true });
21
+ }
22
+
23
+ describe('WorkspaceDetector', () => {
24
+ let tmpDir;
25
+ let createWorkspaceDetector;
26
+
27
+ beforeEach(async () => {
28
+ tmpDir = makeTmpDir();
29
+ const mod = await import('./workspace-detector.js');
30
+ createWorkspaceDetector = mod.createWorkspaceDetector;
31
+ });
32
+
33
+ afterEach(() => {
34
+ if (tmpDir) rmDir(tmpDir);
35
+ });
36
+
37
+ it('detects workspace when projects.json exists in parent directory', () => {
38
+ // workspace/
39
+ // projects.json
40
+ // my-project/
41
+ const wsRoot = tmpDir;
42
+ const projectDir = path.join(wsRoot, 'my-project');
43
+ fs.mkdirSync(projectDir, { recursive: true });
44
+ fs.writeFileSync(path.join(wsRoot, 'projects.json'), '{}');
45
+
46
+ const detector = createWorkspaceDetector();
47
+ const result = detector.detectWorkspace(projectDir);
48
+
49
+ expect(result.isInWorkspace).toBe(true);
50
+ expect(result.workspaceRoot).toBe(wsRoot);
51
+ expect(result.projectPath).toBe(projectDir);
52
+ });
53
+
54
+ it('detects workspace when parent has .tlc.json with workspace: true', () => {
55
+ // workspace/
56
+ // .tlc.json (contains "workspace": true)
57
+ // my-project/
58
+ const wsRoot = tmpDir;
59
+ const projectDir = path.join(wsRoot, 'my-project');
60
+ fs.mkdirSync(projectDir, { recursive: true });
61
+ fs.writeFileSync(
62
+ path.join(wsRoot, '.tlc.json'),
63
+ JSON.stringify({ workspace: true })
64
+ );
65
+
66
+ const detector = createWorkspaceDetector();
67
+ const result = detector.detectWorkspace(projectDir);
68
+
69
+ expect(result.isInWorkspace).toBe(true);
70
+ expect(result.workspaceRoot).toBe(wsRoot);
71
+ });
72
+
73
+ it('detects workspace when parent has memory/ directory', () => {
74
+ // workspace/
75
+ // memory/
76
+ // my-project/
77
+ const wsRoot = tmpDir;
78
+ const projectDir = path.join(wsRoot, 'my-project');
79
+ fs.mkdirSync(projectDir, { recursive: true });
80
+ fs.mkdirSync(path.join(wsRoot, 'memory'), { recursive: true });
81
+
82
+ const detector = createWorkspaceDetector();
83
+ const result = detector.detectWorkspace(projectDir);
84
+
85
+ expect(result.isInWorkspace).toBe(true);
86
+ expect(result.workspaceRoot).toBe(wsRoot);
87
+ });
88
+
89
+ it('returns isInWorkspace=false for standalone project', () => {
90
+ // standalone-project/ (no markers in any parent up to tmpDir)
91
+ const projectDir = path.join(tmpDir, 'standalone-project');
92
+ fs.mkdirSync(projectDir, { recursive: true });
93
+
94
+ // Pass a boundary so we don't walk past tmpDir into real filesystem
95
+ const detector = createWorkspaceDetector({ boundary: tmpDir });
96
+ const result = detector.detectWorkspace(projectDir);
97
+
98
+ expect(result.isInWorkspace).toBe(false);
99
+ expect(result.workspaceRoot).toBeNull();
100
+ expect(result.projectPath).toBe(projectDir);
101
+ expect(result.relativeProjectPath).toBeNull();
102
+ });
103
+
104
+ it('stops at home directory (does not scan above os.homedir())', () => {
105
+ // By default, the detector should not walk above os.homedir().
106
+ // We test this by verifying the detector respects a boundary.
107
+ // Since we can't create dirs above home, we simulate with boundary option.
108
+ const homeDir = os.homedir();
109
+ const detector = createWorkspaceDetector();
110
+
111
+ // The real homedir may or may not be in a workspace - we just verify
112
+ // the detector does not throw and returns a valid shape
113
+ const result = detector.detectWorkspace(homeDir);
114
+ expect(result).toHaveProperty('isInWorkspace');
115
+ expect(result).toHaveProperty('workspaceRoot');
116
+ expect(result).toHaveProperty('projectPath');
117
+ expect(result).toHaveProperty('relativeProjectPath');
118
+ });
119
+
120
+ it('stops at filesystem root', () => {
121
+ // Pass the filesystem root. Should not throw or loop infinitely.
122
+ const detector = createWorkspaceDetector();
123
+ const result = detector.detectWorkspace('/');
124
+
125
+ expect(result.isInWorkspace).toBe(false);
126
+ expect(result.workspaceRoot).toBeNull();
127
+ expect(result.projectPath).toBe('/');
128
+ });
129
+
130
+ it('caches result across calls (same input returns same object)', () => {
131
+ const wsRoot = tmpDir;
132
+ const projectDir = path.join(wsRoot, 'cached-project');
133
+ fs.mkdirSync(projectDir, { recursive: true });
134
+ fs.writeFileSync(path.join(wsRoot, 'projects.json'), '{}');
135
+
136
+ const detector = createWorkspaceDetector();
137
+ const first = detector.detectWorkspace(projectDir);
138
+ const second = detector.detectWorkspace(projectDir);
139
+
140
+ // Same reference - not just equal, but identical object
141
+ expect(first).toBe(second);
142
+ });
143
+
144
+ it('handles nested workspaces (nearest parent wins)', () => {
145
+ // outer-workspace/
146
+ // projects.json
147
+ // inner-workspace/
148
+ // projects.json
149
+ // my-project/
150
+ const outerWs = tmpDir;
151
+ const innerWs = path.join(outerWs, 'inner-workspace');
152
+ const projectDir = path.join(innerWs, 'my-project');
153
+
154
+ fs.mkdirSync(projectDir, { recursive: true });
155
+ fs.writeFileSync(path.join(outerWs, 'projects.json'), '{}');
156
+ fs.writeFileSync(path.join(innerWs, 'projects.json'), '{}');
157
+
158
+ const detector = createWorkspaceDetector();
159
+ const result = detector.detectWorkspace(projectDir);
160
+
161
+ expect(result.isInWorkspace).toBe(true);
162
+ // Nearest parent (inner) should win
163
+ expect(result.workspaceRoot).toBe(innerWs);
164
+ });
165
+
166
+ it('returns correct relative project path', () => {
167
+ const wsRoot = tmpDir;
168
+ const projectDir = path.join(wsRoot, 'apps', 'my-project');
169
+ fs.mkdirSync(projectDir, { recursive: true });
170
+ fs.writeFileSync(path.join(wsRoot, 'projects.json'), '{}');
171
+
172
+ const detector = createWorkspaceDetector();
173
+ const result = detector.detectWorkspace(projectDir);
174
+
175
+ expect(result.isInWorkspace).toBe(true);
176
+ expect(result.relativeProjectPath).toBe(path.join('apps', 'my-project'));
177
+ expect(result.projectPath).toBe(projectDir);
178
+ });
179
+
180
+ it('works when project IS the workspace root itself', () => {
181
+ // The project dir itself has workspace markers
182
+ const projectDir = tmpDir;
183
+ fs.writeFileSync(path.join(projectDir, 'projects.json'), '{}');
184
+
185
+ const detector = createWorkspaceDetector();
186
+ const result = detector.detectWorkspace(projectDir);
187
+
188
+ expect(result.isInWorkspace).toBe(true);
189
+ expect(result.workspaceRoot).toBe(projectDir);
190
+ expect(result.projectPath).toBe(projectDir);
191
+ expect(result.relativeProjectPath).toBe('.');
192
+ });
193
+ });