tlc-claude-code 2.3.0 → 2.4.1

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.
@@ -0,0 +1,336 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { routeCommand } from './command-router.js';
3
+
4
+ /**
5
+ * Create default mock dependencies for routeCommand.
6
+ * @param {Object} overrides - Override individual deps
7
+ * @returns {Object} Deps object with all mocks
8
+ */
9
+ function createMockDeps(overrides = {}) {
10
+ return {
11
+ resolveRouting: vi.fn(() => ({
12
+ models: ['claude'],
13
+ strategy: 'single',
14
+ source: 'shipped-defaults',
15
+ })),
16
+ packagePrompt: vi.fn(() => 'packaged-prompt-text'),
17
+ dispatch: vi.fn(async () => ({
18
+ stdout: 'cli output',
19
+ stderr: '',
20
+ exitCode: 0,
21
+ duration: 500,
22
+ })),
23
+ readRouterState: vi.fn(() => ({
24
+ providers: {
25
+ claude: { available: true, path: '/opt/homebrew/bin/claude' },
26
+ codex: { available: true, path: '/opt/homebrew/bin/codex' },
27
+ gemini: { available: false, path: '' },
28
+ },
29
+ })),
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ describe('command-router', () => {
35
+ describe('routeCommand', () => {
36
+ it('routes to inline when model is claude', async () => {
37
+ const deps = createMockDeps();
38
+
39
+ const result = await routeCommand({
40
+ command: 'build',
41
+ agentPrompt: 'Build the feature',
42
+ flagModel: null,
43
+ context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
44
+ }, deps);
45
+
46
+ expect(result).toEqual({ type: 'inline', model: 'claude' });
47
+ expect(deps.resolveRouting).toHaveBeenCalledWith(expect.objectContaining({ command: 'build' }));
48
+ });
49
+
50
+ it('routes to CLI dispatch when model is codex', async () => {
51
+ const deps = createMockDeps({
52
+ resolveRouting: vi.fn(() => ({
53
+ models: ['codex'],
54
+ strategy: 'single',
55
+ source: 'personal-config',
56
+ providers: {
57
+ codex: { type: 'cli', command: 'codex', flags: ['--quiet'] },
58
+ },
59
+ })),
60
+ });
61
+
62
+ const result = await routeCommand({
63
+ command: 'review',
64
+ agentPrompt: 'Review the code',
65
+ flagModel: null,
66
+ context: { projectDoc: 'My Project', planDoc: null, codingStandards: null, files: null },
67
+ }, deps);
68
+
69
+ expect(result.type).toBe('cli');
70
+ expect(result.model).toBe('codex');
71
+ expect(result.output).toBe('cli output');
72
+ expect(result.exitCode).toBe(0);
73
+ expect(result.duration).toBe(500);
74
+ expect(deps.packagePrompt).toHaveBeenCalled();
75
+ expect(deps.dispatch).toHaveBeenCalled();
76
+ });
77
+
78
+ it('parallel strategy returns both inline and CLI results', async () => {
79
+ const deps = createMockDeps({
80
+ resolveRouting: vi.fn(() => ({
81
+ models: ['claude', 'codex'],
82
+ strategy: 'parallel',
83
+ source: 'project-override',
84
+ providers: {
85
+ codex: { type: 'cli', command: 'codex', flags: [] },
86
+ },
87
+ })),
88
+ dispatch: vi.fn(async () => ({
89
+ stdout: 'codex says hello',
90
+ stderr: '',
91
+ exitCode: 0,
92
+ duration: 800,
93
+ })),
94
+ });
95
+
96
+ const result = await routeCommand({
97
+ command: 'build',
98
+ agentPrompt: 'Build it',
99
+ flagModel: null,
100
+ context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
101
+ }, deps);
102
+
103
+ expect(result.type).toBe('parallel');
104
+ expect(result.results).toHaveLength(2);
105
+
106
+ const claudeResult = result.results.find(r => r.model === 'claude');
107
+ const codexResult = result.results.find(r => r.model === 'codex');
108
+ expect(claudeResult).toEqual({ model: 'claude', type: 'inline' });
109
+ expect(codexResult.type).toBe('cli');
110
+ expect(codexResult.model).toBe('codex');
111
+ expect(codexResult.output).toBe('codex says hello');
112
+ });
113
+
114
+ it('unavailable provider falls back to inline with warning', async () => {
115
+ const deps = createMockDeps({
116
+ resolveRouting: vi.fn(() => ({
117
+ models: ['gemini'],
118
+ strategy: 'single',
119
+ source: 'personal-config',
120
+ // No provider config — truly unknown model
121
+ })),
122
+ readRouterState: vi.fn(() => ({
123
+ providers: {
124
+ claude: { available: true },
125
+ },
126
+ })),
127
+ });
128
+
129
+ const result = await routeCommand({
130
+ command: 'review',
131
+ agentPrompt: 'Review this',
132
+ flagModel: null,
133
+ context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
134
+ }, deps);
135
+
136
+ expect(result.type).toBe('inline');
137
+ expect(result.model).toBe('claude');
138
+ expect(result.warning).toMatch(/gemini/i);
139
+ });
140
+
141
+ it('flag override routes to specified model', async () => {
142
+ const deps = createMockDeps({
143
+ resolveRouting: vi.fn(() => ({
144
+ models: ['codex'],
145
+ strategy: 'single',
146
+ source: 'flag-override',
147
+ providers: {
148
+ codex: { type: 'cli', command: 'codex', flags: ['--quiet'] },
149
+ },
150
+ })),
151
+ });
152
+
153
+ const result = await routeCommand({
154
+ command: 'build',
155
+ agentPrompt: 'Build it',
156
+ flagModel: 'codex',
157
+ context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
158
+ }, deps);
159
+
160
+ expect(result.type).toBe('cli');
161
+ expect(result.model).toBe('codex');
162
+ expect(deps.resolveRouting).toHaveBeenCalledWith(expect.objectContaining({
163
+ flagModel: 'codex',
164
+ }));
165
+ });
166
+
167
+ it('packages prompt with all context fields', async () => {
168
+ const context = {
169
+ projectDoc: 'Project doc content',
170
+ planDoc: 'Plan doc content',
171
+ codingStandards: 'Standards content',
172
+ files: [{ path: 'src/app.js', content: 'const x = 1;' }],
173
+ };
174
+
175
+ const deps = createMockDeps({
176
+ resolveRouting: vi.fn(() => ({
177
+ models: ['codex'],
178
+ strategy: 'single',
179
+ source: 'personal-config',
180
+ providers: {
181
+ codex: { type: 'cli', command: 'codex', flags: [] },
182
+ },
183
+ })),
184
+ });
185
+
186
+ await routeCommand({
187
+ command: 'build',
188
+ agentPrompt: 'Build the feature',
189
+ flagModel: null,
190
+ context,
191
+ }, deps);
192
+
193
+ expect(deps.packagePrompt).toHaveBeenCalledWith(expect.objectContaining({
194
+ agentPrompt: 'Build the feature',
195
+ projectDoc: 'Project doc content',
196
+ planDoc: 'Plan doc content',
197
+ codingStandards: 'Standards content',
198
+ files: [{ path: 'src/app.js', content: 'const x = 1;' }],
199
+ }));
200
+ });
201
+
202
+ it('CLI dispatch timeout handled gracefully', async () => {
203
+ const deps = createMockDeps({
204
+ resolveRouting: vi.fn(() => ({
205
+ models: ['codex'],
206
+ strategy: 'single',
207
+ source: 'personal-config',
208
+ providers: {
209
+ codex: { type: 'cli', command: 'codex', flags: [] },
210
+ },
211
+ })),
212
+ dispatch: vi.fn(async () => ({
213
+ stdout: '',
214
+ stderr: 'Process timed out',
215
+ exitCode: -1,
216
+ duration: 120000,
217
+ })),
218
+ });
219
+
220
+ const result = await routeCommand({
221
+ command: 'build',
222
+ agentPrompt: 'Build it',
223
+ flagModel: null,
224
+ context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
225
+ }, deps);
226
+
227
+ expect(result.type).toBe('cli');
228
+ expect(result.model).toBe('codex');
229
+ expect(result.exitCode).toBe(-1);
230
+ expect(result.output).toBe('');
231
+ });
232
+
233
+ it('non-zero exit code from CLI captured in result', async () => {
234
+ const deps = createMockDeps({
235
+ resolveRouting: vi.fn(() => ({
236
+ models: ['codex'],
237
+ strategy: 'single',
238
+ source: 'personal-config',
239
+ providers: {
240
+ codex: { type: 'cli', command: 'codex', flags: [] },
241
+ },
242
+ })),
243
+ dispatch: vi.fn(async () => ({
244
+ stdout: 'partial output',
245
+ stderr: 'error occurred',
246
+ exitCode: 1,
247
+ duration: 300,
248
+ })),
249
+ });
250
+
251
+ const result = await routeCommand({
252
+ command: 'build',
253
+ agentPrompt: 'Build it',
254
+ flagModel: null,
255
+ context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
256
+ }, deps);
257
+
258
+ expect(result.type).toBe('cli');
259
+ expect(result.exitCode).toBe(1);
260
+ expect(result.output).toBe('partial output');
261
+ });
262
+
263
+ it('unknown model falls back to inline', async () => {
264
+ const deps = createMockDeps({
265
+ resolveRouting: vi.fn(() => ({
266
+ models: ['unknown-model'],
267
+ strategy: 'single',
268
+ source: 'flag-override',
269
+ })),
270
+ readRouterState: vi.fn(() => ({
271
+ providers: {
272
+ claude: { available: true, path: '/opt/homebrew/bin/claude' },
273
+ },
274
+ })),
275
+ });
276
+
277
+ const result = await routeCommand({
278
+ command: 'build',
279
+ agentPrompt: 'Build it',
280
+ flagModel: 'unknown-model',
281
+ context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
282
+ }, deps);
283
+
284
+ expect(result.type).toBe('inline');
285
+ expect(result.model).toBe('claude');
286
+ expect(result.warning).toMatch(/unknown-model/i);
287
+ });
288
+
289
+ it('parallel with one unavailable provider still runs available ones', async () => {
290
+ const deps = createMockDeps({
291
+ resolveRouting: vi.fn(() => ({
292
+ models: ['claude', 'codex', 'gemini'],
293
+ strategy: 'parallel',
294
+ source: 'project-override',
295
+ providers: {
296
+ codex: { type: 'cli', command: 'codex', flags: [] },
297
+ // gemini has no provider config — truly unavailable
298
+ },
299
+ })),
300
+ readRouterState: vi.fn(() => ({
301
+ providers: {
302
+ claude: { available: true },
303
+ codex: { available: true },
304
+ // gemini not in router state either
305
+ },
306
+ })),
307
+ dispatch: vi.fn(async () => ({
308
+ stdout: 'codex output',
309
+ stderr: '',
310
+ exitCode: 0,
311
+ duration: 400,
312
+ })),
313
+ });
314
+
315
+ const result = await routeCommand({
316
+ command: 'build',
317
+ agentPrompt: 'Build it',
318
+ flagModel: null,
319
+ context: { projectDoc: null, planDoc: null, codingStandards: null, files: null },
320
+ }, deps);
321
+
322
+ expect(result.type).toBe('parallel');
323
+ expect(result.results).toHaveLength(3);
324
+
325
+ const [claudeResult, codexResult, geminiResult] = result.results;
326
+
327
+ expect(claudeResult).toEqual({ model: 'claude', type: 'inline' });
328
+ expect(codexResult.type).toBe('cli');
329
+ expect(codexResult.model).toBe('codex');
330
+ // Gemini is unavailable — skipped (not duplicate Claude)
331
+ expect(geminiResult.type).toBe('skipped');
332
+ expect(geminiResult.model).toBe('gemini');
333
+ expect(geminiResult.warning).toMatch(/gemini/i);
334
+ });
335
+ });
336
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Prompt Packager Module
3
+ * Serializes project context into a single prompt string for CLI dispatch to external LLMs.
4
+ */
5
+
6
+ const DEFAULT_TOKEN_BUDGET = 100000;
7
+ const TRUNCATION_MARKER = '...';
8
+
9
+ /**
10
+ * Format a source file as a labeled block for inclusion in the prompt.
11
+ * @param {string} filePath - Relative path to the file
12
+ * @param {string} content - File contents
13
+ * @returns {string} Formatted block with path header
14
+ */
15
+ function formatFileBlock(filePath, content) {
16
+ return `--- ${filePath} ---\n${content}`;
17
+ }
18
+
19
+ /**
20
+ * Truncate text to fit within a character budget.
21
+ * When truncation is needed, cuts at a character boundary and appends an ellipsis marker.
22
+ * @param {string} text - The text to truncate
23
+ * @param {number} budget - Maximum character count
24
+ * @returns {string} The text, truncated if necessary
25
+ */
26
+ function truncateToBudget(text, budget) {
27
+ if (budget <= 0) return '';
28
+ if (text.length <= budget) return text;
29
+ const cutLen = budget - TRUNCATION_MARKER.length;
30
+ if (cutLen <= 0) return text.slice(0, budget);
31
+ return text.slice(0, cutLen) + TRUNCATION_MARKER;
32
+ }
33
+
34
+ /**
35
+ * Package project context and an agent prompt into a single string for external LLM dispatch.
36
+ * @param {object} opts
37
+ * @param {string} opts.agentPrompt - The command's instruction text (required)
38
+ * @param {string|null} [opts.projectDoc] - Contents of PROJECT.md
39
+ * @param {string|null} [opts.planDoc] - Contents of current phase PLAN.md
40
+ * @param {string|null} [opts.codingStandards] - Contents of CODING-STANDARDS.md
41
+ * @param {Array<{path: string, content: string}>|null} [opts.files] - Relevant source files
42
+ * @param {number} [opts.tokenBudget=100000] - Maximum characters in output
43
+ * @returns {string} Serialized prompt string
44
+ */
45
+ function packagePrompt({
46
+ agentPrompt,
47
+ projectDoc = null,
48
+ planDoc = null,
49
+ codingStandards = null,
50
+ files = null,
51
+ tokenBudget = DEFAULT_TOKEN_BUDGET,
52
+ }) {
53
+ if (tokenBudget <= 0) return '';
54
+
55
+ // Pre-truncate large individual file contents to a per-file budget.
56
+ // Each file gets at most half the total budget to leave room for other sections.
57
+ const perFileBudget = Math.floor(tokenBudget / 2);
58
+ const sortedFiles =
59
+ files && files.length > 0
60
+ ? [...files]
61
+ .sort((a, b) => a.path.localeCompare(b.path))
62
+ .map((f) => ({
63
+ path: f.path,
64
+ content: truncateToBudget(f.content, perFileBudget),
65
+ }))
66
+ : null;
67
+
68
+ // Task section goes FIRST so truncation of context never drops the instruction
69
+ const sections = [];
70
+
71
+ sections.push(`--- Task ---\n${agentPrompt}`);
72
+
73
+ sections.push('\nYou are working on the following project:');
74
+
75
+ if (projectDoc != null) {
76
+ sections.push(`\n--- PROJECT.md ---\n${projectDoc}`);
77
+ }
78
+
79
+ if (planDoc != null) {
80
+ sections.push(`\n--- Current Phase Plan ---\n${planDoc}`);
81
+ }
82
+
83
+ if (codingStandards != null) {
84
+ sections.push(`\n--- Coding Standards ---\n${codingStandards}`);
85
+ }
86
+
87
+ if (sortedFiles && sortedFiles.length > 0) {
88
+ const fileBlocks = sortedFiles
89
+ .map((f) => formatFileBlock(f.path, f.content))
90
+ .join('\n\n');
91
+ sections.push(`\n--- Relevant Files ---\n\n${fileBlocks}`);
92
+ }
93
+
94
+ const full = sections.join('\n');
95
+ return truncateToBudget(full, tokenBudget);
96
+ }
97
+
98
+ module.exports = { packagePrompt, truncateToBudget, formatFileBlock };
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ packagePrompt,
4
+ truncateToBudget,
5
+ formatFileBlock,
6
+ } from './prompt-packager.js';
7
+
8
+ describe('prompt-packager', () => {
9
+ describe('formatFileBlock', () => {
10
+ it('produces correct header format', () => {
11
+ const result = formatFileBlock('src/auth/login.ts', 'const x = 1;');
12
+ expect(result).toBe('--- src/auth/login.ts ---\nconst x = 1;');
13
+ });
14
+
15
+ it('handles empty content', () => {
16
+ const result = formatFileBlock('empty.js', '');
17
+ expect(result).toBe('--- empty.js ---\n');
18
+ });
19
+ });
20
+
21
+ describe('truncateToBudget', () => {
22
+ it('returns text unchanged when within budget', () => {
23
+ const text = 'Hello world';
24
+ expect(truncateToBudget(text, 100)).toBe(text);
25
+ });
26
+
27
+ it('truncates at char boundary with ellipsis marker', () => {
28
+ const text = 'abcdefghij'; // 10 chars
29
+ const result = truncateToBudget(text, 8);
30
+ expect(result.length).toBeLessThanOrEqual(8);
31
+ expect(result).toContain('...');
32
+ });
33
+
34
+ it('returns empty string for budget of 0', () => {
35
+ expect(truncateToBudget('anything', 0)).toBe('');
36
+ });
37
+
38
+ it('handles text exactly at budget', () => {
39
+ const text = 'exact';
40
+ expect(truncateToBudget(text, 5)).toBe('exact');
41
+ });
42
+
43
+ it('handles empty text', () => {
44
+ expect(truncateToBudget('', 100)).toBe('');
45
+ });
46
+ });
47
+
48
+ describe('packagePrompt', () => {
49
+ const fullInput = {
50
+ agentPrompt: 'Implement the login feature',
51
+ projectDoc: '# My Project\nA cool project.',
52
+ planDoc: '## Phase 1\n- [ ] Task 1',
53
+ codingStandards: '## Standards\nUse JSDoc.',
54
+ files: [
55
+ { path: 'src/auth/login.ts', content: 'export function login() {}' },
56
+ { path: 'tests/auth/login.test.ts', content: 'test("login", () => {})' },
57
+ ],
58
+ };
59
+
60
+ it('packages all sections when everything provided', () => {
61
+ const result = packagePrompt(fullInput);
62
+ expect(result).toContain('You are working on the following project:');
63
+ expect(result).toContain('--- PROJECT.md ---');
64
+ expect(result).toContain('# My Project');
65
+ expect(result).toContain('--- Current Phase Plan ---');
66
+ expect(result).toContain('## Phase 1');
67
+ expect(result).toContain('--- Coding Standards ---');
68
+ expect(result).toContain('## Standards');
69
+ expect(result).toContain('--- Relevant Files ---');
70
+ expect(result).toContain('--- src/auth/login.ts ---');
71
+ expect(result).toContain('export function login() {}');
72
+ expect(result).toContain('--- tests/auth/login.test.ts ---');
73
+ expect(result).toContain('test("login", () => {})');
74
+ expect(result).toContain('--- Task ---');
75
+ expect(result).toContain('Implement the login feature');
76
+ });
77
+
78
+ it('omits PROJECT.md section when null', () => {
79
+ const result = packagePrompt({ ...fullInput, projectDoc: null });
80
+ expect(result).not.toContain('--- PROJECT.md ---');
81
+ expect(result).toContain('--- Current Phase Plan ---');
82
+ expect(result).toContain('--- Task ---');
83
+ });
84
+
85
+ it('omits plan section when null', () => {
86
+ const result = packagePrompt({ ...fullInput, planDoc: null });
87
+ expect(result).not.toContain('--- Current Phase Plan ---');
88
+ expect(result).toContain('--- PROJECT.md ---');
89
+ expect(result).toContain('--- Task ---');
90
+ });
91
+
92
+ it('omits coding standards when null', () => {
93
+ const result = packagePrompt({ ...fullInput, codingStandards: null });
94
+ expect(result).not.toContain('--- Coding Standards ---');
95
+ expect(result).toContain('--- PROJECT.md ---');
96
+ expect(result).toContain('--- Task ---');
97
+ });
98
+
99
+ it('omits files section when null', () => {
100
+ const result = packagePrompt({ ...fullInput, files: null });
101
+ expect(result).not.toContain('--- Relevant Files ---');
102
+ expect(result).toContain('--- PROJECT.md ---');
103
+ expect(result).toContain('--- Task ---');
104
+ });
105
+
106
+ it('omits files section when empty array', () => {
107
+ const result = packagePrompt({ ...fullInput, files: [] });
108
+ expect(result).not.toContain('--- Relevant Files ---');
109
+ expect(result).toContain('--- Task ---');
110
+ });
111
+
112
+ it('empty agentPrompt still produces valid output', () => {
113
+ const result = packagePrompt({ ...fullInput, agentPrompt: '' });
114
+ expect(result).toContain('--- Task ---');
115
+ expect(result).toContain('--- PROJECT.md ---');
116
+ });
117
+
118
+ it('files sorted by path for deterministic output', () => {
119
+ const result = packagePrompt({
120
+ agentPrompt: 'do it',
121
+ files: [
122
+ { path: 'z/last.js', content: 'last' },
123
+ { path: 'a/first.js', content: 'first' },
124
+ { path: 'm/middle.js', content: 'middle' },
125
+ ],
126
+ });
127
+ const aIdx = result.indexOf('--- a/first.js ---');
128
+ const mIdx = result.indexOf('--- m/middle.js ---');
129
+ const zIdx = result.indexOf('--- z/last.js ---');
130
+ expect(aIdx).toBeLessThan(mIdx);
131
+ expect(mIdx).toBeLessThan(zIdx);
132
+ });
133
+
134
+ it('truncates to budget when total exceeds limit', () => {
135
+ const result = packagePrompt({
136
+ ...fullInput,
137
+ tokenBudget: 50,
138
+ });
139
+ expect(result.length).toBeLessThanOrEqual(50);
140
+ });
141
+
142
+ it('budget of 0 returns empty string', () => {
143
+ const result = packagePrompt({ ...fullInput, tokenBudget: 0 });
144
+ expect(result).toBe('');
145
+ });
146
+
147
+ it('large individual files truncated before total budget check', () => {
148
+ const largeContent = 'x'.repeat(200000);
149
+ const result = packagePrompt({
150
+ agentPrompt: 'task',
151
+ files: [{ path: 'big.js', content: largeContent }],
152
+ tokenBudget: 1000,
153
+ });
154
+ expect(result.length).toBeLessThanOrEqual(1000);
155
+ // The file should be present but truncated
156
+ expect(result).toContain('--- big.js ---');
157
+ });
158
+
159
+ it('places task section before context so truncation never drops the instruction', () => {
160
+ const result = packagePrompt(fullInput);
161
+ const taskIdx = result.indexOf('--- Task ---');
162
+ const projectIdx = result.indexOf('--- PROJECT.md ---');
163
+ expect(taskIdx).toBeLessThan(projectIdx);
164
+ });
165
+
166
+ it('uses default tokenBudget of 100000 when not specified', () => {
167
+ const largeProject = 'x'.repeat(200000);
168
+ const result = packagePrompt({
169
+ agentPrompt: 'task',
170
+ projectDoc: largeProject,
171
+ });
172
+ expect(result.length).toBeLessThanOrEqual(100000);
173
+ });
174
+
175
+ it('handles minimal input with only agentPrompt', () => {
176
+ const result = packagePrompt({ agentPrompt: 'just do it' });
177
+ expect(result).toContain('--- Task ---');
178
+ expect(result).toContain('just do it');
179
+ expect(result).not.toContain('--- PROJECT.md ---');
180
+ expect(result).not.toContain('--- Current Phase Plan ---');
181
+ expect(result).not.toContain('--- Coding Standards ---');
182
+ expect(result).not.toContain('--- Relevant Files ---');
183
+ });
184
+ });
185
+ });