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.
- package/.claude/commands/tlc/bootstrap.md +77 -0
- package/.claude/commands/tlc/build.md +20 -6
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/recall.md +87 -0
- package/.claude/commands/tlc/remember.md +71 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +96 -201
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +240 -1
- package/server/lib/bug-writer.js +204 -0
- package/server/lib/bug-writer.test.js +279 -0
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/claude-cascade.js +247 -0
- package/server/lib/claude-cascade.test.js +245 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/context-injection.js +121 -0
- package/server/lib/context-injection.test.js +340 -0
- package/server/lib/conversation-chunker.js +320 -0
- package/server/lib/conversation-chunker.test.js +573 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/embedding-client.js +160 -0
- package/server/lib/embedding-client.test.js +243 -0
- package/server/lib/global-config.js +198 -0
- package/server/lib/global-config.test.js +288 -0
- package/server/lib/inherited-search.js +184 -0
- package/server/lib/inherited-search.test.js +343 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +182 -0
- package/server/lib/memory-api.test.js +320 -0
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +415 -0
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +139 -0
- package/server/lib/memory-inheritance.js +179 -0
- package/server/lib/memory-inheritance.test.js +360 -0
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/plan-writer.js +196 -0
- package/server/lib/plan-writer.test.js +298 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +302 -0
- package/server/lib/project-scanner.test.js +541 -0
- package/server/lib/project-status.js +302 -0
- package/server/lib/project-status.test.js +470 -0
- package/server/lib/projects-registry.js +237 -0
- package/server/lib/projects-registry.test.js +275 -0
- package/server/lib/recall-command.js +207 -0
- package/server/lib/recall-command.test.js +306 -0
- package/server/lib/remember-command.js +98 -0
- package/server/lib/remember-command.test.js +288 -0
- package/server/lib/rich-capture.js +221 -0
- package/server/lib/rich-capture.test.js +312 -0
- package/server/lib/roadmap-api.js +200 -0
- package/server/lib/roadmap-api.test.js +318 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +242 -0
- package/server/lib/semantic-recall.test.js +463 -0
- package/server/lib/setup-generator.js +315 -0
- package/server/lib/setup-generator.test.js +303 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/test-inventory.js +112 -0
- package/server/lib/test-inventory.test.js +360 -0
- package/server/lib/vector-indexer.js +246 -0
- package/server/lib/vector-indexer.test.js +459 -0
- package/server/lib/vector-store.js +260 -0
- package/server/lib/vector-store.test.js +706 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +992 -0
- package/server/lib/workspace-api.test.js +1217 -0
- package/server/lib/workspace-bootstrap.js +164 -0
- package/server/lib/workspace-bootstrap.test.js +503 -0
- package/server/lib/workspace-context.js +129 -0
- package/server/lib/workspace-context.test.js +214 -0
- package/server/lib/workspace-detector.js +162 -0
- package/server/lib/workspace-detector.test.js +193 -0
- package/server/lib/workspace-init.js +307 -0
- package/server/lib/workspace-init.test.js +244 -0
- package/server/lib/workspace-snapshot.js +236 -0
- package/server/lib/workspace-snapshot.test.js +444 -0
- package/server/lib/workspace-watcher.js +162 -0
- package/server/lib/workspace-watcher.test.js +257 -0
- package/server/package-lock.json +1306 -17
- package/server/package.json +7 -0
- package/dashboard-web/dist/assets/index-B1I_joSL.js +0 -393
- package/dashboard-web/dist/assets/index-B1I_joSL.js.map +0 -1
- 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
|
+
});
|