tlc-claude-code 2.3.0 → 2.4.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/autofix.md +31 -0
- package/.claude/commands/tlc/build.md +31 -0
- package/.claude/commands/tlc/coverage.md +31 -0
- package/.claude/commands/tlc/discuss.md +31 -0
- package/.claude/commands/tlc/docs.md +31 -0
- package/.claude/commands/tlc/edge-cases.md +31 -0
- package/.claude/commands/tlc/plan.md +31 -0
- package/.claude/commands/tlc/quick.md +31 -0
- package/.claude/commands/tlc/review.md +31 -0
- package/.claude/hooks/tlc-session-init.sh +14 -3
- package/bin/setup-autoupdate.js +316 -87
- package/bin/setup-autoupdate.test.js +454 -34
- package/package.json +1 -1
- package/scripts/project-docs.js +1 -1
- package/server/lib/cli-dispatcher.js +98 -0
- package/server/lib/cli-dispatcher.test.js +249 -0
- package/server/lib/command-router.js +171 -0
- package/server/lib/command-router.test.js +336 -0
- package/server/lib/prompt-packager.js +98 -0
- package/server/lib/prompt-packager.test.js +185 -0
- package/server/lib/routing-command.js +159 -0
- package/server/lib/routing-command.test.js +290 -0
- package/server/lib/task-router-config.js +142 -0
- package/server/lib/task-router-config.test.js +428 -0
- package/server/setup.sh +271 -271
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
resolveRouting,
|
|
4
|
+
loadPersonalConfig,
|
|
5
|
+
loadProjectOverride,
|
|
6
|
+
SHIPPED_DEFAULTS,
|
|
7
|
+
ROUTABLE_COMMANDS,
|
|
8
|
+
} from './task-router-config.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Helper: build a mock fs with readFileSync that serves different
|
|
12
|
+
* content per path. Paths not in the map throw ENOENT.
|
|
13
|
+
*/
|
|
14
|
+
function mockFs(fileMap = {}) {
|
|
15
|
+
return {
|
|
16
|
+
readFileSync(path, _encoding) {
|
|
17
|
+
if (fileMap[path] !== undefined) {
|
|
18
|
+
return fileMap[path];
|
|
19
|
+
}
|
|
20
|
+
const err = new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
21
|
+
err.code = 'ENOENT';
|
|
22
|
+
throw err;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('task-router-config', () => {
|
|
28
|
+
// ── SHIPPED_DEFAULTS ──────────────────────────────────────────────
|
|
29
|
+
describe('SHIPPED_DEFAULTS', () => {
|
|
30
|
+
it('maps every routable command to claude/single', () => {
|
|
31
|
+
for (const cmd of ROUTABLE_COMMANDS) {
|
|
32
|
+
expect(SHIPPED_DEFAULTS[cmd]).toEqual({
|
|
33
|
+
models: ['claude'],
|
|
34
|
+
strategy: 'single',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// ── ROUTABLE_COMMANDS ─────────────────────────────────────────────
|
|
41
|
+
describe('ROUTABLE_COMMANDS', () => {
|
|
42
|
+
it('contains the expected commands', () => {
|
|
43
|
+
const expected = [
|
|
44
|
+
'build', 'plan', 'review', 'test', 'coverage',
|
|
45
|
+
'autofix', 'discuss', 'docs', 'edge-cases', 'quick',
|
|
46
|
+
];
|
|
47
|
+
for (const cmd of expected) {
|
|
48
|
+
expect(ROUTABLE_COMMANDS).toContain(cmd);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── loadPersonalConfig ────────────────────────────────────────────
|
|
54
|
+
describe('loadPersonalConfig', () => {
|
|
55
|
+
it('returns parsed config when file exists', () => {
|
|
56
|
+
const personalConfig = {
|
|
57
|
+
task_routing: {
|
|
58
|
+
build: { models: ['codex'], strategy: 'single' },
|
|
59
|
+
},
|
|
60
|
+
model_providers: {
|
|
61
|
+
codex: { type: 'cli', command: 'codex' },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
const fs = mockFs({
|
|
65
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const result = loadPersonalConfig({ homeDir: '/home/user', fs });
|
|
69
|
+
expect(result).toEqual(personalConfig);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns null when file does not exist', () => {
|
|
73
|
+
const fs = mockFs({});
|
|
74
|
+
const result = loadPersonalConfig({ homeDir: '/home/user', fs });
|
|
75
|
+
expect(result).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('returns null for malformed JSON', () => {
|
|
79
|
+
const fs = mockFs({
|
|
80
|
+
'/home/user/.tlc/config.json': '{ not valid json',
|
|
81
|
+
});
|
|
82
|
+
const result = loadPersonalConfig({ homeDir: '/home/user', fs });
|
|
83
|
+
expect(result).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── loadProjectOverride ───────────────────────────────────────────
|
|
88
|
+
describe('loadProjectOverride', () => {
|
|
89
|
+
it('returns task_routing_override section when present', () => {
|
|
90
|
+
const tlcJson = {
|
|
91
|
+
task_routing_override: {
|
|
92
|
+
build: { models: ['local'], strategy: 'single' },
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const fs = mockFs({
|
|
96
|
+
'/project/.tlc.json': JSON.stringify(tlcJson),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = loadProjectOverride({ projectDir: '/project', fs });
|
|
100
|
+
expect(result).toEqual(tlcJson.task_routing_override);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns null when .tlc.json has no task_routing_override', () => {
|
|
104
|
+
const fs = mockFs({
|
|
105
|
+
'/project/.tlc.json': JSON.stringify({ router: {} }),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = loadProjectOverride({ projectDir: '/project', fs });
|
|
109
|
+
expect(result).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns null when .tlc.json does not exist', () => {
|
|
113
|
+
const fs = mockFs({});
|
|
114
|
+
const result = loadProjectOverride({ projectDir: '/project', fs });
|
|
115
|
+
expect(result).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns null for malformed JSON', () => {
|
|
119
|
+
const fs = mockFs({
|
|
120
|
+
'/project/.tlc.json': 'broken!!!',
|
|
121
|
+
});
|
|
122
|
+
const result = loadProjectOverride({ projectDir: '/project', fs });
|
|
123
|
+
expect(result).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// ── resolveRouting ────────────────────────────────────────────────
|
|
128
|
+
describe('resolveRouting', () => {
|
|
129
|
+
it('returns shipped defaults when no config exists', () => {
|
|
130
|
+
const fs = mockFs({});
|
|
131
|
+
const result = resolveRouting({
|
|
132
|
+
command: 'build',
|
|
133
|
+
projectDir: '/project',
|
|
134
|
+
homeDir: '/home/user',
|
|
135
|
+
fs,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(result).toEqual({
|
|
139
|
+
models: ['claude'],
|
|
140
|
+
strategy: 'single',
|
|
141
|
+
source: 'shipped-defaults',
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('personal config overrides defaults', () => {
|
|
146
|
+
const personalConfig = {
|
|
147
|
+
task_routing: {
|
|
148
|
+
build: { models: ['codex'], strategy: 'single' },
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const fs = mockFs({
|
|
152
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const result = resolveRouting({
|
|
156
|
+
command: 'build',
|
|
157
|
+
projectDir: '/project',
|
|
158
|
+
homeDir: '/home/user',
|
|
159
|
+
fs,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
expect(result).toEqual({
|
|
163
|
+
models: ['codex'],
|
|
164
|
+
strategy: 'single',
|
|
165
|
+
source: 'personal-config',
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('project override overrides personal config', () => {
|
|
170
|
+
const personalConfig = {
|
|
171
|
+
task_routing: {
|
|
172
|
+
build: { models: ['codex'], strategy: 'single' },
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const tlcJson = {
|
|
176
|
+
task_routing_override: {
|
|
177
|
+
build: { models: ['local'], strategy: 'single' },
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
const fs = mockFs({
|
|
181
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
182
|
+
'/project/.tlc.json': JSON.stringify(tlcJson),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const result = resolveRouting({
|
|
186
|
+
command: 'build',
|
|
187
|
+
projectDir: '/project',
|
|
188
|
+
homeDir: '/home/user',
|
|
189
|
+
fs,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(result).toEqual({
|
|
193
|
+
models: ['local'],
|
|
194
|
+
strategy: 'single',
|
|
195
|
+
source: 'project-override',
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('flag override overrides everything', () => {
|
|
200
|
+
const personalConfig = {
|
|
201
|
+
task_routing: {
|
|
202
|
+
build: { models: ['codex'], strategy: 'single' },
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const tlcJson = {
|
|
206
|
+
task_routing_override: {
|
|
207
|
+
build: { models: ['local'], strategy: 'single' },
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
const fs = mockFs({
|
|
211
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
212
|
+
'/project/.tlc.json': JSON.stringify(tlcJson),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const result = resolveRouting({
|
|
216
|
+
command: 'build',
|
|
217
|
+
flagModel: 'gemini',
|
|
218
|
+
projectDir: '/project',
|
|
219
|
+
homeDir: '/home/user',
|
|
220
|
+
fs,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
expect(result).toEqual({
|
|
224
|
+
models: ['gemini'],
|
|
225
|
+
strategy: 'single',
|
|
226
|
+
source: 'flag-override',
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('unknown command returns defaults (claude, single)', () => {
|
|
231
|
+
const fs = mockFs({});
|
|
232
|
+
const result = resolveRouting({
|
|
233
|
+
command: 'nonexistent-command',
|
|
234
|
+
projectDir: '/project',
|
|
235
|
+
homeDir: '/home/user',
|
|
236
|
+
fs,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result).toEqual({
|
|
240
|
+
models: ['claude'],
|
|
241
|
+
strategy: 'single',
|
|
242
|
+
source: 'shipped-defaults',
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('personal config for one command does not affect another', () => {
|
|
247
|
+
const personalConfig = {
|
|
248
|
+
task_routing: {
|
|
249
|
+
review: { models: ['claude', 'codex'], strategy: 'parallel' },
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
const fs = mockFs({
|
|
253
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// 'review' should use personal config
|
|
257
|
+
const reviewResult = resolveRouting({
|
|
258
|
+
command: 'review',
|
|
259
|
+
projectDir: '/project',
|
|
260
|
+
homeDir: '/home/user',
|
|
261
|
+
fs,
|
|
262
|
+
});
|
|
263
|
+
expect(reviewResult).toEqual({
|
|
264
|
+
models: ['claude', 'codex'],
|
|
265
|
+
strategy: 'parallel',
|
|
266
|
+
source: 'personal-config',
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// 'build' should still use defaults
|
|
270
|
+
const buildResult = resolveRouting({
|
|
271
|
+
command: 'build',
|
|
272
|
+
projectDir: '/project',
|
|
273
|
+
homeDir: '/home/user',
|
|
274
|
+
fs,
|
|
275
|
+
});
|
|
276
|
+
expect(buildResult).toEqual({
|
|
277
|
+
models: ['claude'],
|
|
278
|
+
strategy: 'single',
|
|
279
|
+
source: 'shipped-defaults',
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('partial personal config merges correctly (only strategy)', () => {
|
|
284
|
+
const personalConfig = {
|
|
285
|
+
task_routing: {
|
|
286
|
+
build: { strategy: 'parallel' },
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
const fs = mockFs({
|
|
290
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const result = resolveRouting({
|
|
294
|
+
command: 'build',
|
|
295
|
+
projectDir: '/project',
|
|
296
|
+
homeDir: '/home/user',
|
|
297
|
+
fs,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// Should keep default models but use personal strategy
|
|
301
|
+
expect(result.strategy).toBe('parallel');
|
|
302
|
+
expect(result.models).toEqual(['claude']);
|
|
303
|
+
expect(result.source).toBe('personal-config');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('partial personal config merges correctly (only models)', () => {
|
|
307
|
+
const personalConfig = {
|
|
308
|
+
task_routing: {
|
|
309
|
+
build: { models: ['codex'] },
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
const fs = mockFs({
|
|
313
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const result = resolveRouting({
|
|
317
|
+
command: 'build',
|
|
318
|
+
projectDir: '/project',
|
|
319
|
+
homeDir: '/home/user',
|
|
320
|
+
fs,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(result.models).toEqual(['codex']);
|
|
324
|
+
expect(result.strategy).toBe('single');
|
|
325
|
+
expect(result.source).toBe('personal-config');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('missing personal config file handled gracefully', () => {
|
|
329
|
+
const fs = mockFs({
|
|
330
|
+
'/project/.tlc.json': JSON.stringify({
|
|
331
|
+
task_routing_override: {
|
|
332
|
+
build: { models: ['local'], strategy: 'single' },
|
|
333
|
+
},
|
|
334
|
+
}),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const result = resolveRouting({
|
|
338
|
+
command: 'build',
|
|
339
|
+
projectDir: '/project',
|
|
340
|
+
homeDir: '/home/user',
|
|
341
|
+
fs,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(result).toEqual({
|
|
345
|
+
models: ['local'],
|
|
346
|
+
strategy: 'single',
|
|
347
|
+
source: 'project-override',
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('malformed personal JSON handled gracefully', () => {
|
|
352
|
+
const fs = mockFs({
|
|
353
|
+
'/home/user/.tlc/config.json': '{{{{not json!',
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const result = resolveRouting({
|
|
357
|
+
command: 'build',
|
|
358
|
+
projectDir: '/project',
|
|
359
|
+
homeDir: '/home/user',
|
|
360
|
+
fs,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(result).toEqual({
|
|
364
|
+
models: ['claude'],
|
|
365
|
+
strategy: 'single',
|
|
366
|
+
source: 'shipped-defaults',
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('malformed project JSON handled gracefully', () => {
|
|
371
|
+
const fs = mockFs({
|
|
372
|
+
'/project/.tlc.json': 'not json either!',
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const result = resolveRouting({
|
|
376
|
+
command: 'build',
|
|
377
|
+
projectDir: '/project',
|
|
378
|
+
homeDir: '/home/user',
|
|
379
|
+
fs,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
expect(result).toEqual({
|
|
383
|
+
models: ['claude'],
|
|
384
|
+
strategy: 'single',
|
|
385
|
+
source: 'shipped-defaults',
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('model_providers loaded from personal config', () => {
|
|
390
|
+
const personalConfig = {
|
|
391
|
+
task_routing: {
|
|
392
|
+
build: { models: ['codex'], strategy: 'single' },
|
|
393
|
+
},
|
|
394
|
+
model_providers: {
|
|
395
|
+
claude: { type: 'inline' },
|
|
396
|
+
codex: { type: 'cli', command: 'codex' },
|
|
397
|
+
gemini: { type: 'cli', command: 'gemini' },
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
const fs = mockFs({
|
|
401
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const result = resolveRouting({
|
|
405
|
+
command: 'build',
|
|
406
|
+
projectDir: '/project',
|
|
407
|
+
homeDir: '/home/user',
|
|
408
|
+
fs,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(result.models).toEqual(['codex']);
|
|
412
|
+
expect(result.providers).toEqual(personalConfig.model_providers);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('returns no providers when personal config has none', () => {
|
|
416
|
+
const fs = mockFs({});
|
|
417
|
+
|
|
418
|
+
const result = resolveRouting({
|
|
419
|
+
command: 'build',
|
|
420
|
+
projectDir: '/project',
|
|
421
|
+
homeDir: '/home/user',
|
|
422
|
+
fs,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
expect(result.providers).toBeUndefined();
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
});
|