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,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE.md Cascade
|
|
3
|
+
*
|
|
4
|
+
* When working in a child project inside a workspace, this module injects
|
|
5
|
+
* workspace-level CLAUDE.md content into the project's context. Only relevant
|
|
6
|
+
* sections (coding standards, conventions, architecture, rules) are cascaded,
|
|
7
|
+
* and a token budget (max 2000 chars) prevents context bloat.
|
|
8
|
+
*
|
|
9
|
+
* Workspace content is injected between TLC-WORKSPACE-START/END markers in
|
|
10
|
+
* the project CLAUDE.md so that project-specific rules always take precedence.
|
|
11
|
+
*
|
|
12
|
+
* @module claude-cascade
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const WORKSPACE_MARKERS = {
|
|
19
|
+
START: '<!-- TLC-WORKSPACE-START -->',
|
|
20
|
+
END: '<!-- TLC-WORKSPACE-END -->',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Section heading keywords that are considered relevant for cascading.
|
|
25
|
+
* Matched case-insensitively against ## headings in the workspace CLAUDE.md.
|
|
26
|
+
*/
|
|
27
|
+
const RELEVANT_KEYWORDS = [
|
|
28
|
+
'standards',
|
|
29
|
+
'conventions',
|
|
30
|
+
'architecture',
|
|
31
|
+
'rules',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Maximum character budget for workspace content injection.
|
|
36
|
+
* Rough token estimate: 4 chars per token, so 2000 chars ~ 500 tokens.
|
|
37
|
+
*/
|
|
38
|
+
const MAX_WORKSPACE_CHARS = 2000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read a file and return its content, or null if it doesn't exist.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} filePath - Absolute path to the file
|
|
44
|
+
* @returns {Promise<string|null>}
|
|
45
|
+
*/
|
|
46
|
+
async function readFileOrNull(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
return await fs.promises.readFile(filePath, 'utf-8');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (err.code === 'ENOENT') return null;
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract relevant sections from workspace CLAUDE.md content.
|
|
57
|
+
*
|
|
58
|
+
* A section starts with a `## Heading` line and ends just before the next
|
|
59
|
+
* `## Heading` or end-of-file. Only sections whose heading contains one
|
|
60
|
+
* of the RELEVANT_KEYWORDS (case-insensitive) are included.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} content - Full workspace CLAUDE.md content
|
|
63
|
+
* @returns {string} Filtered content with only relevant sections
|
|
64
|
+
*/
|
|
65
|
+
function extractRelevantSections(content) {
|
|
66
|
+
const lines = content.split('\n');
|
|
67
|
+
const sections = [];
|
|
68
|
+
let currentSection = null;
|
|
69
|
+
let currentLines = [];
|
|
70
|
+
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
const headingMatch = line.match(/^##\s+(.+)/);
|
|
73
|
+
if (headingMatch) {
|
|
74
|
+
// Save previous section if relevant
|
|
75
|
+
if (currentSection !== null) {
|
|
76
|
+
sections.push({ heading: currentSection, lines: [...currentLines] });
|
|
77
|
+
}
|
|
78
|
+
currentSection = headingMatch[1];
|
|
79
|
+
currentLines = [line];
|
|
80
|
+
} else if (currentSection !== null) {
|
|
81
|
+
currentLines.push(line);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Save last section
|
|
86
|
+
if (currentSection !== null) {
|
|
87
|
+
sections.push({ heading: currentSection, lines: [...currentLines] });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Filter to only relevant sections
|
|
91
|
+
const relevant = sections.filter((section) => {
|
|
92
|
+
const headingLower = section.heading.toLowerCase();
|
|
93
|
+
return RELEVANT_KEYWORDS.some((kw) => headingLower.includes(kw));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return relevant.map((s) => s.lines.join('\n')).join('\n\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Truncate content to the character budget, breaking at the last newline
|
|
101
|
+
* before the limit to avoid cutting mid-line.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} content - Content to truncate
|
|
104
|
+
* @param {number} maxChars - Maximum character count
|
|
105
|
+
* @returns {string} Truncated content
|
|
106
|
+
*/
|
|
107
|
+
function truncateTobudget(content, maxChars) {
|
|
108
|
+
if (content.length <= maxChars) return content;
|
|
109
|
+
|
|
110
|
+
const truncated = content.slice(0, maxChars);
|
|
111
|
+
const lastNewline = truncated.lastIndexOf('\n');
|
|
112
|
+
if (lastNewline > 0) {
|
|
113
|
+
return truncated.slice(0, lastNewline);
|
|
114
|
+
}
|
|
115
|
+
return truncated;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the merged content with workspace section injected between markers,
|
|
120
|
+
* followed by the original project content (minus any existing markers).
|
|
121
|
+
*
|
|
122
|
+
* @param {string|null} workspaceContent - Filtered workspace content to inject
|
|
123
|
+
* @param {string|null} projectContent - Original project CLAUDE.md content
|
|
124
|
+
* @returns {string} Merged content
|
|
125
|
+
*/
|
|
126
|
+
function buildMergedContent(workspaceContent, projectContent) {
|
|
127
|
+
if (!workspaceContent && !projectContent) return '';
|
|
128
|
+
|
|
129
|
+
// If no workspace content, return project as-is (strip any old markers)
|
|
130
|
+
if (!workspaceContent) {
|
|
131
|
+
return stripWorkspaceMarkers(projectContent || '');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const truncated = truncateTobudget(workspaceContent, MAX_WORKSPACE_CHARS);
|
|
135
|
+
|
|
136
|
+
const workspaceBlock = [
|
|
137
|
+
WORKSPACE_MARKERS.START,
|
|
138
|
+
truncated,
|
|
139
|
+
WORKSPACE_MARKERS.END,
|
|
140
|
+
].join('\n');
|
|
141
|
+
|
|
142
|
+
// If no project content, just return workspace block
|
|
143
|
+
if (!projectContent) {
|
|
144
|
+
return workspaceBlock + '\n';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Strip existing workspace markers from project content
|
|
148
|
+
const cleanedProject = stripWorkspaceMarkers(projectContent).trim();
|
|
149
|
+
|
|
150
|
+
// Workspace first, then project (project takes precedence by being last)
|
|
151
|
+
return workspaceBlock + '\n\n' + cleanedProject + '\n';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Remove existing TLC-WORKSPACE markers and their content from a string.
|
|
156
|
+
*
|
|
157
|
+
* @param {string} content - Content to strip markers from
|
|
158
|
+
* @returns {string} Content with markers removed
|
|
159
|
+
*/
|
|
160
|
+
function stripWorkspaceMarkers(content) {
|
|
161
|
+
const startIdx = content.indexOf(WORKSPACE_MARKERS.START);
|
|
162
|
+
const endIdx = content.indexOf(WORKSPACE_MARKERS.END);
|
|
163
|
+
|
|
164
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
|
|
165
|
+
return content;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const before = content.slice(0, startIdx);
|
|
169
|
+
const after = content.slice(endIdx + WORKSPACE_MARKERS.END.length);
|
|
170
|
+
|
|
171
|
+
// Clean up extra blank lines at the junction
|
|
172
|
+
return (before + after).replace(/\n{3,}/g, '\n\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create a CLAUDE.md cascade instance.
|
|
177
|
+
*
|
|
178
|
+
* @param {Object} options
|
|
179
|
+
* @param {Object} options.workspaceDetector - A workspace detector instance
|
|
180
|
+
* with a `detectWorkspace(projectDir)` method.
|
|
181
|
+
* @returns {{ getCascadedContext: Function, syncCascade: Function }}
|
|
182
|
+
*/
|
|
183
|
+
export function createClaudeCascade({ workspaceDetector }) {
|
|
184
|
+
/**
|
|
185
|
+
* Get merged context for a project directory.
|
|
186
|
+
*
|
|
187
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
188
|
+
* @returns {Promise<{ workspaceContent: string|null, projectContent: string|null, merged: string }>}
|
|
189
|
+
*/
|
|
190
|
+
async function getCascadedContext(projectDir) {
|
|
191
|
+
const result = workspaceDetector.detectWorkspace(projectDir);
|
|
192
|
+
const projectClaudeMd = path.join(projectDir, 'CLAUDE.md');
|
|
193
|
+
const projectContent = await readFileOrNull(projectClaudeMd);
|
|
194
|
+
|
|
195
|
+
// If not in a workspace, return project content only
|
|
196
|
+
if (!result.isInWorkspace || !result.workspaceRoot) {
|
|
197
|
+
return {
|
|
198
|
+
workspaceContent: null,
|
|
199
|
+
projectContent,
|
|
200
|
+
merged: projectContent || '',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Read workspace CLAUDE.md
|
|
205
|
+
const wsClaudeMd = path.join(result.workspaceRoot, 'CLAUDE.md');
|
|
206
|
+
const rawWorkspaceContent = await readFileOrNull(wsClaudeMd);
|
|
207
|
+
|
|
208
|
+
if (!rawWorkspaceContent) {
|
|
209
|
+
return {
|
|
210
|
+
workspaceContent: null,
|
|
211
|
+
projectContent,
|
|
212
|
+
merged: projectContent || '',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Extract only relevant sections
|
|
217
|
+
const relevantContent = extractRelevantSections(rawWorkspaceContent);
|
|
218
|
+
const workspaceContent = relevantContent || rawWorkspaceContent;
|
|
219
|
+
|
|
220
|
+
const merged = buildMergedContent(relevantContent || null, projectContent);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
workspaceContent: rawWorkspaceContent,
|
|
224
|
+
projectContent,
|
|
225
|
+
merged,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Sync workspace content into the project's CLAUDE.md file on disk.
|
|
231
|
+
*
|
|
232
|
+
* Writes the merged content back to the project's CLAUDE.md, placing
|
|
233
|
+
* workspace content between TLC-WORKSPACE markers and preserving all
|
|
234
|
+
* project-specific content after the markers.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
237
|
+
* @returns {Promise<void>}
|
|
238
|
+
*/
|
|
239
|
+
async function syncCascade(projectDir) {
|
|
240
|
+
const context = await getCascadedContext(projectDir);
|
|
241
|
+
const claudeMdPath = path.join(projectDir, 'CLAUDE.md');
|
|
242
|
+
|
|
243
|
+
await fs.promises.writeFile(claudeMdPath, context.merged, 'utf-8');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { getCascadedContext, syncCascade };
|
|
247
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLAUDE.md Cascade Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for injecting workspace-level CLAUDE.md content into child project context.
|
|
5
|
+
* Uses temp directories with actual CLAUDE.md files and a mocked workspaceDetector.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { createClaudeCascade } from './claude-cascade.js';
|
|
13
|
+
|
|
14
|
+
/** Create a unique temp directory for each test */
|
|
15
|
+
function makeTmpDir() {
|
|
16
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-cascade-'));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Recursively remove a directory */
|
|
20
|
+
function rmDir(dir) {
|
|
21
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('ClaudeCascade', () => {
|
|
25
|
+
let tmpDir;
|
|
26
|
+
let wsRoot;
|
|
27
|
+
let projectDir;
|
|
28
|
+
let mockDetector;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpDir = makeTmpDir();
|
|
32
|
+
wsRoot = path.join(tmpDir, 'workspace');
|
|
33
|
+
projectDir = path.join(wsRoot, 'my-project');
|
|
34
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
35
|
+
|
|
36
|
+
mockDetector = {
|
|
37
|
+
detectWorkspace: vi.fn().mockReturnValue({
|
|
38
|
+
isInWorkspace: true,
|
|
39
|
+
workspaceRoot: wsRoot,
|
|
40
|
+
projectPath: projectDir,
|
|
41
|
+
relativeProjectPath: 'my-project',
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
if (tmpDir) rmDir(tmpDir);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('getCascadedContext', () => {
|
|
51
|
+
it('reads workspace CLAUDE.md content', async () => {
|
|
52
|
+
const wsContent = '# Workspace Rules\n\n## Coding Standards\n\nUse semicolons.\n';
|
|
53
|
+
fs.writeFileSync(path.join(wsRoot, 'CLAUDE.md'), wsContent);
|
|
54
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), '# Project\n');
|
|
55
|
+
|
|
56
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
57
|
+
const context = await cascade.getCascadedContext(projectDir);
|
|
58
|
+
|
|
59
|
+
expect(context.workspaceContent).toContain('Workspace Rules');
|
|
60
|
+
expect(context.workspaceContent).toContain('Use semicolons');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reads project CLAUDE.md content', async () => {
|
|
64
|
+
fs.writeFileSync(path.join(wsRoot, 'CLAUDE.md'), '# Workspace\n');
|
|
65
|
+
const projContent = '# My Project\n\nProject-specific rules.\n';
|
|
66
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), projContent);
|
|
67
|
+
|
|
68
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
69
|
+
const context = await cascade.getCascadedContext(projectDir);
|
|
70
|
+
|
|
71
|
+
expect(context.projectContent).toContain('My Project');
|
|
72
|
+
expect(context.projectContent).toContain('Project-specific rules');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('injects workspace content between TLC-WORKSPACE-START/END markers', async () => {
|
|
76
|
+
fs.writeFileSync(path.join(wsRoot, 'CLAUDE.md'), '# Workspace\n\n## Coding Standards\n\nFollow TDD.\n');
|
|
77
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), '# Project\n');
|
|
78
|
+
|
|
79
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
80
|
+
const context = await cascade.getCascadedContext(projectDir);
|
|
81
|
+
|
|
82
|
+
expect(context.merged).toContain('<!-- TLC-WORKSPACE-START -->');
|
|
83
|
+
expect(context.merged).toContain('<!-- TLC-WORKSPACE-END -->');
|
|
84
|
+
expect(context.merged).toContain('Follow TDD');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('project rules take precedence (project content appears after workspace)', async () => {
|
|
88
|
+
fs.writeFileSync(path.join(wsRoot, 'CLAUDE.md'), '# Workspace\n\n## Conventions\n\nWorkspace convention.\n');
|
|
89
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), '# Project\n\nProject rule.\n');
|
|
90
|
+
|
|
91
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
92
|
+
const context = await cascade.getCascadedContext(projectDir);
|
|
93
|
+
|
|
94
|
+
const wsStartIdx = context.merged.indexOf('<!-- TLC-WORKSPACE-START -->');
|
|
95
|
+
const wsEndIdx = context.merged.indexOf('<!-- TLC-WORKSPACE-END -->');
|
|
96
|
+
const projectIdx = context.merged.indexOf('Project rule');
|
|
97
|
+
|
|
98
|
+
// Project content must appear after the workspace section
|
|
99
|
+
expect(projectIdx).toBeGreaterThan(wsEndIdx);
|
|
100
|
+
// Workspace content is between markers
|
|
101
|
+
expect(wsStartIdx).toBeLessThan(wsEndIdx);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('only relevant sections injected (coding standards, conventions, architecture)', async () => {
|
|
105
|
+
const wsContent = [
|
|
106
|
+
'# Workspace',
|
|
107
|
+
'',
|
|
108
|
+
'## Coding Standards',
|
|
109
|
+
'',
|
|
110
|
+
'Use ESLint.',
|
|
111
|
+
'',
|
|
112
|
+
'## Architecture',
|
|
113
|
+
'',
|
|
114
|
+
'Microservices pattern.',
|
|
115
|
+
'',
|
|
116
|
+
'## Personal Notes',
|
|
117
|
+
'',
|
|
118
|
+
'Remember to buy milk.',
|
|
119
|
+
'',
|
|
120
|
+
'## Conventions',
|
|
121
|
+
'',
|
|
122
|
+
'Naming: camelCase.',
|
|
123
|
+
'',
|
|
124
|
+
'## Random Stuff',
|
|
125
|
+
'',
|
|
126
|
+
'Not relevant.',
|
|
127
|
+
'',
|
|
128
|
+
].join('\n');
|
|
129
|
+
fs.writeFileSync(path.join(wsRoot, 'CLAUDE.md'), wsContent);
|
|
130
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), '# Project\n');
|
|
131
|
+
|
|
132
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
133
|
+
const context = await cascade.getCascadedContext(projectDir);
|
|
134
|
+
|
|
135
|
+
// Relevant sections should be present
|
|
136
|
+
expect(context.merged).toContain('Use ESLint');
|
|
137
|
+
expect(context.merged).toContain('Microservices pattern');
|
|
138
|
+
expect(context.merged).toContain('Naming: camelCase');
|
|
139
|
+
|
|
140
|
+
// Irrelevant sections should NOT be present in workspace injection
|
|
141
|
+
const wsStart = context.merged.indexOf('<!-- TLC-WORKSPACE-START -->');
|
|
142
|
+
const wsEnd = context.merged.indexOf('<!-- TLC-WORKSPACE-END -->');
|
|
143
|
+
const wsSection = context.merged.slice(wsStart, wsEnd);
|
|
144
|
+
expect(wsSection).not.toContain('Remember to buy milk');
|
|
145
|
+
expect(wsSection).not.toContain('Not relevant');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('token budget respected (truncates workspace content if > 2000 chars)', async () => {
|
|
149
|
+
// Create a workspace CLAUDE.md with a relevant section longer than 2000 chars
|
|
150
|
+
const longContent = '## Coding Standards\n\n' + 'A'.repeat(3000) + '\n';
|
|
151
|
+
fs.writeFileSync(path.join(wsRoot, 'CLAUDE.md'), longContent);
|
|
152
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), '# Project\n');
|
|
153
|
+
|
|
154
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
155
|
+
const context = await cascade.getCascadedContext(projectDir);
|
|
156
|
+
|
|
157
|
+
// Extract workspace section from merged
|
|
158
|
+
const wsStart = context.merged.indexOf('<!-- TLC-WORKSPACE-START -->');
|
|
159
|
+
const wsEnd = context.merged.indexOf('<!-- TLC-WORKSPACE-END -->');
|
|
160
|
+
const wsSection = context.merged.slice(
|
|
161
|
+
wsStart + '<!-- TLC-WORKSPACE-START -->'.length,
|
|
162
|
+
wsEnd
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Workspace section content should be truncated to max 2000 chars
|
|
166
|
+
expect(wsSection.trim().length).toBeLessThanOrEqual(2000);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('no cascade when no workspace detected (standalone project)', async () => {
|
|
170
|
+
const standaloneDetector = {
|
|
171
|
+
detectWorkspace: vi.fn().mockReturnValue({
|
|
172
|
+
isInWorkspace: false,
|
|
173
|
+
workspaceRoot: null,
|
|
174
|
+
projectPath: projectDir,
|
|
175
|
+
relativeProjectPath: null,
|
|
176
|
+
}),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), '# Standalone\n\nProject content.\n');
|
|
180
|
+
|
|
181
|
+
const cascade = createClaudeCascade({ workspaceDetector: standaloneDetector });
|
|
182
|
+
const context = await cascade.getCascadedContext(projectDir);
|
|
183
|
+
|
|
184
|
+
expect(context.workspaceContent).toBeNull();
|
|
185
|
+
expect(context.projectContent).toContain('Standalone');
|
|
186
|
+
expect(context.merged).toContain('Project content');
|
|
187
|
+
expect(context.merged).not.toContain('<!-- TLC-WORKSPACE-START -->');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('handles missing workspace CLAUDE.md gracefully', async () => {
|
|
191
|
+
// No CLAUDE.md in workspace root
|
|
192
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), '# Project\n\nRules.\n');
|
|
193
|
+
|
|
194
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
195
|
+
const context = await cascade.getCascadedContext(projectDir);
|
|
196
|
+
|
|
197
|
+
expect(context.workspaceContent).toBeNull();
|
|
198
|
+
expect(context.projectContent).toContain('Project');
|
|
199
|
+
expect(context.merged).toContain('Rules');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('handles missing project CLAUDE.md gracefully', async () => {
|
|
203
|
+
fs.writeFileSync(path.join(wsRoot, 'CLAUDE.md'), '# Workspace\n\n## Coding Standards\n\nWs rules.\n');
|
|
204
|
+
// No CLAUDE.md in project dir
|
|
205
|
+
|
|
206
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
207
|
+
const context = await cascade.getCascadedContext(projectDir);
|
|
208
|
+
|
|
209
|
+
expect(context.projectContent).toBeNull();
|
|
210
|
+
expect(context.workspaceContent).toContain('Workspace');
|
|
211
|
+
expect(context.merged).toContain('Ws rules');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('syncCascade', () => {
|
|
216
|
+
it('syncCascade updates markers in project CLAUDE.md', async () => {
|
|
217
|
+
fs.writeFileSync(path.join(wsRoot, 'CLAUDE.md'), '# Workspace\n\n## Coding Standards\n\nUse Vitest.\n');
|
|
218
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), '# Project\n\nMy rules.\n');
|
|
219
|
+
|
|
220
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
221
|
+
await cascade.syncCascade(projectDir);
|
|
222
|
+
|
|
223
|
+
const updated = fs.readFileSync(path.join(projectDir, 'CLAUDE.md'), 'utf-8');
|
|
224
|
+
expect(updated).toContain('<!-- TLC-WORKSPACE-START -->');
|
|
225
|
+
expect(updated).toContain('<!-- TLC-WORKSPACE-END -->');
|
|
226
|
+
expect(updated).toContain('Use Vitest');
|
|
227
|
+
expect(updated).toContain('My rules');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('marker-based replacement is idempotent (running syncCascade twice gives same result)', async () => {
|
|
231
|
+
fs.writeFileSync(path.join(wsRoot, 'CLAUDE.md'), '# Workspace\n\n## Rules\n\nWorkspace rules.\n## Conventions\n\nBe consistent.\n');
|
|
232
|
+
fs.writeFileSync(path.join(projectDir, 'CLAUDE.md'), '# Project\n\nProject stuff.\n');
|
|
233
|
+
|
|
234
|
+
const cascade = createClaudeCascade({ workspaceDetector: mockDetector });
|
|
235
|
+
|
|
236
|
+
await cascade.syncCascade(projectDir);
|
|
237
|
+
const afterFirst = fs.readFileSync(path.join(projectDir, 'CLAUDE.md'), 'utf-8');
|
|
238
|
+
|
|
239
|
+
await cascade.syncCascade(projectDir);
|
|
240
|
+
const afterSecond = fs.readFileSync(path.join(projectDir, 'CLAUDE.md'), 'utf-8');
|
|
241
|
+
|
|
242
|
+
expect(afterFirst).toBe(afterSecond);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command Runner — execute TLC commands via container, Claude Code, or queue
|
|
3
|
+
* Phase 80 Task 8
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execSync, spawn } = require('child_process');
|
|
9
|
+
|
|
10
|
+
const VALID_COMMANDS = ['build', 'deploy', 'test', 'plan', 'verify', 'review', 'status'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Create command runner
|
|
14
|
+
* @param {Object} [options]
|
|
15
|
+
* @param {Function} [options._checkDocker] - Check if tlc-standalone image exists
|
|
16
|
+
* @param {Function} [options._checkClaude] - Check if Claude Code is running
|
|
17
|
+
* @returns {Object} Command runner API
|
|
18
|
+
*/
|
|
19
|
+
function createCommandRunner(options = {}) {
|
|
20
|
+
const checkDocker = options._checkDocker || defaultCheckDocker;
|
|
21
|
+
const checkClaude = options._checkClaude || defaultCheckClaude;
|
|
22
|
+
|
|
23
|
+
async function defaultCheckDocker() {
|
|
24
|
+
try {
|
|
25
|
+
execSync('docker image inspect tlc-standalone 2>/dev/null', { stdio: 'pipe' });
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultCheckClaude() {
|
|
33
|
+
try {
|
|
34
|
+
const result = execSync('pgrep -f "claude" 2>/dev/null', { stdio: 'pipe' });
|
|
35
|
+
return result.toString().trim().length > 0;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Detect best execution method
|
|
43
|
+
* @param {string} projectPath
|
|
44
|
+
* @returns {Promise<string>} 'container' | 'claude-code' | 'queue'
|
|
45
|
+
*/
|
|
46
|
+
async function detectExecutionMethod(projectPath) {
|
|
47
|
+
if (await checkDocker()) return 'container';
|
|
48
|
+
if (checkClaude()) return 'claude-code';
|
|
49
|
+
return 'queue';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Execute command via Docker container
|
|
54
|
+
* @param {string} projectPath
|
|
55
|
+
* @param {string} command
|
|
56
|
+
* @param {Function} onOutput
|
|
57
|
+
* @returns {Promise<{ exitCode: number }>}
|
|
58
|
+
*/
|
|
59
|
+
async function executeViaContainer(projectPath, command, onOutput) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const proc = spawn('docker', [
|
|
62
|
+
'run', '--rm',
|
|
63
|
+
'-v', `${projectPath}:/project`,
|
|
64
|
+
'-w', '/project',
|
|
65
|
+
'tlc-standalone',
|
|
66
|
+
'tlc', command,
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
proc.stdout.on('data', (data) => onOutput && onOutput(data.toString()));
|
|
70
|
+
proc.stderr.on('data', (data) => onOutput && onOutput(data.toString()));
|
|
71
|
+
proc.on('close', (code) => resolve({ exitCode: code }));
|
|
72
|
+
proc.on('error', (err) => reject(err));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Queue command as task in PLAN.md
|
|
78
|
+
* @param {string} projectPath
|
|
79
|
+
* @param {string} command
|
|
80
|
+
*/
|
|
81
|
+
async function queueCommand(projectPath, command) {
|
|
82
|
+
const timestamp = new Date().toISOString();
|
|
83
|
+
const entry = `\n### Queued: tlc ${command}\n_Queued at ${timestamp}_\n`;
|
|
84
|
+
|
|
85
|
+
// Find most recent plan file
|
|
86
|
+
const planDir = path.join(projectPath, '.planning', 'phases');
|
|
87
|
+
if (fs.existsSync(planDir)) {
|
|
88
|
+
const plans = fs.readdirSync(planDir).filter(f => f.endsWith('-PLAN.md')).sort((a, b) => {
|
|
89
|
+
const numA = parseInt(a.match(/^(\d+)/)?.[1] || '0', 10);
|
|
90
|
+
const numB = parseInt(b.match(/^(\d+)/)?.[1] || '0', 10);
|
|
91
|
+
return numA - numB;
|
|
92
|
+
});
|
|
93
|
+
if (plans.length > 0) {
|
|
94
|
+
const planPath = path.join(planDir, plans[plans.length - 1]);
|
|
95
|
+
fs.appendFileSync(planPath, entry);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Log to history
|
|
100
|
+
logCommand(projectPath, { command, timestamp, method: 'queue' });
|
|
101
|
+
|
|
102
|
+
return { queued: true, method: 'queue', command };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Log a command to history
|
|
107
|
+
*/
|
|
108
|
+
function logCommand(projectPath, entry) {
|
|
109
|
+
const histPath = path.join(projectPath, '.tlc', 'command-history.json');
|
|
110
|
+
const dir = path.dirname(histPath);
|
|
111
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
112
|
+
|
|
113
|
+
let history = [];
|
|
114
|
+
try {
|
|
115
|
+
if (fs.existsSync(histPath)) {
|
|
116
|
+
history = JSON.parse(fs.readFileSync(histPath, 'utf8'));
|
|
117
|
+
}
|
|
118
|
+
} catch {}
|
|
119
|
+
history.push(entry);
|
|
120
|
+
// Keep last 100
|
|
121
|
+
if (history.length > 100) history = history.slice(-100);
|
|
122
|
+
fs.writeFileSync(histPath, JSON.stringify(history, null, 2));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get command history for a project
|
|
127
|
+
* @param {string} projectPath
|
|
128
|
+
* @returns {Array}
|
|
129
|
+
*/
|
|
130
|
+
function getCommandHistory(projectPath) {
|
|
131
|
+
const histPath = path.join(projectPath, '.tlc', 'command-history.json');
|
|
132
|
+
try {
|
|
133
|
+
if (fs.existsSync(histPath)) {
|
|
134
|
+
return JSON.parse(fs.readFileSync(histPath, 'utf8'));
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate command type
|
|
142
|
+
* @param {string} command
|
|
143
|
+
* @returns {boolean}
|
|
144
|
+
*/
|
|
145
|
+
function validateCommand(command) {
|
|
146
|
+
if (!command || typeof command !== 'string') return false;
|
|
147
|
+
return VALID_COMMANDS.includes(command);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
detectExecutionMethod,
|
|
152
|
+
executeViaContainer,
|
|
153
|
+
queueCommand,
|
|
154
|
+
getCommandHistory,
|
|
155
|
+
validateCommand,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { createCommandRunner };
|