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,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 };