tlc-claude-code 2.4.2 → 2.4.3
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/build.md +68 -0
- package/.claude/commands/tlc/discuss.md +174 -123
- package/.claude/commands/tlc/e2e-verify.md +1 -1
- package/.claude/commands/tlc/plan.md +77 -2
- package/.claude/commands/tlc/tlc.md +204 -473
- package/CLAUDE.md +6 -5
- package/package.json +4 -1
- package/scripts/dev-link.sh +29 -0
- package/scripts/test-package.sh +54 -0
- package/scripts/version-sync.js +42 -0
- package/scripts/version-sync.test.js +100 -0
- package/server/lib/model-router.js +11 -2
- package/server/lib/model-router.test.js +27 -1
- package/server/lib/orchestration/codex-orchestrator.js +185 -0
- package/server/lib/orchestration/codex-orchestrator.test.js +221 -0
- package/server/lib/orchestration/dep-linker.js +61 -0
- package/server/lib/orchestration/dep-linker.test.js +174 -0
- package/server/lib/router-config.js +18 -3
- package/server/lib/router-config.test.js +57 -1
- package/server/lib/routing/index.js +34 -0
- package/server/lib/routing/index.test.js +33 -0
- package/server/lib/routing-command.js +11 -2
- package/server/lib/routing-command.test.js +39 -1
- package/server/lib/routing-preamble.integration.test.js +319 -0
- package/server/lib/routing-preamble.js +34 -11
- package/server/lib/routing-preamble.test.js +11 -0
- package/server/lib/task-router-config.js +35 -14
- package/server/lib/task-router-config.test.js +77 -13
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
showRouting,
|
|
4
4
|
showProviders,
|
|
@@ -8,6 +8,10 @@ import {
|
|
|
8
8
|
} from './routing-command.js';
|
|
9
9
|
|
|
10
10
|
describe('routing-command', () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
11
15
|
// ── showRouting ───────────────────────────────────────────────────
|
|
12
16
|
describe('showRouting', () => {
|
|
13
17
|
it('returns routing for all routable commands', () => {
|
|
@@ -204,6 +208,40 @@ describe('routing-command', () => {
|
|
|
204
208
|
const parsed = JSON.parse(writtenData);
|
|
205
209
|
expect(parsed.task_routing).toEqual(routing);
|
|
206
210
|
});
|
|
211
|
+
|
|
212
|
+
it('warns when existing personal config JSON is invalid', () => {
|
|
213
|
+
const routing = { build: { models: ['codex'], strategy: 'single' } };
|
|
214
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
215
|
+
const fs = {
|
|
216
|
+
existsSync: vi.fn(() => true),
|
|
217
|
+
readFileSync: vi.fn(() => '{invalid json'),
|
|
218
|
+
mkdirSync: vi.fn(),
|
|
219
|
+
writeFileSync: vi.fn(),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
223
|
+
|
|
224
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
225
|
+
'[TLC WARNING] Failed to parse existing personal routing config; overwriting task_routing'
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('stays silent when personal config file is missing', () => {
|
|
230
|
+
const routing = { build: { models: ['codex'], strategy: 'single' } };
|
|
231
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
232
|
+
const error = new Error('ENOENT');
|
|
233
|
+
error.code = 'ENOENT';
|
|
234
|
+
const fs = {
|
|
235
|
+
existsSync: vi.fn(() => false),
|
|
236
|
+
readFileSync: vi.fn(() => { throw error; }),
|
|
237
|
+
mkdirSync: vi.fn(),
|
|
238
|
+
writeFileSync: vi.fn(),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
savePersonalRouting({ routing, homeDir: '/home/user', fs });
|
|
242
|
+
|
|
243
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
244
|
+
});
|
|
207
245
|
});
|
|
208
246
|
|
|
209
247
|
// ── formatRoutingTable ────────────────────────────────────────────
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
7
|
+
import routingPreamble from './routing-preamble.js';
|
|
8
|
+
|
|
9
|
+
const { generatePreamble } = routingPreamble;
|
|
10
|
+
|
|
11
|
+
const createdDirs = [];
|
|
12
|
+
|
|
13
|
+
function makeTempDir(name) {
|
|
14
|
+
const dir = path.join(os.tmpdir(), `tlc-routing-${name}-${crypto.randomUUID()}`);
|
|
15
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
16
|
+
createdDirs.push(dir);
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeJson(filePath, value) {
|
|
21
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
22
|
+
fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeFile(filePath, contents) {
|
|
26
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
27
|
+
fs.writeFileSync(filePath, contents);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runPreamble({
|
|
31
|
+
commandName,
|
|
32
|
+
flagModel,
|
|
33
|
+
personalConfig,
|
|
34
|
+
personalConfigRaw,
|
|
35
|
+
projectConfig,
|
|
36
|
+
projectConfigRaw,
|
|
37
|
+
projectDirName = 'project',
|
|
38
|
+
} = {}) {
|
|
39
|
+
const homeDir = makeTempDir('home');
|
|
40
|
+
const cwd = makeTempDir(projectDirName);
|
|
41
|
+
|
|
42
|
+
if (personalConfig !== undefined) {
|
|
43
|
+
writeJson(path.join(homeDir, '.tlc', 'config.json'), personalConfig);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (personalConfigRaw !== undefined) {
|
|
47
|
+
writeFile(path.join(homeDir, '.tlc', 'config.json'), personalConfigRaw);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (projectConfig !== undefined) {
|
|
51
|
+
writeJson(path.join(cwd, '.tlc.json'), projectConfig);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (projectConfigRaw !== undefined) {
|
|
55
|
+
writeFile(path.join(cwd, '.tlc.json'), projectConfigRaw);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const command = flagModel
|
|
59
|
+
? `${generatePreamble(commandName)} -- ${JSON.stringify(flagModel)}`
|
|
60
|
+
: generatePreamble(commandName);
|
|
61
|
+
|
|
62
|
+
const completed = spawnSync('bash', ['-lc', command], {
|
|
63
|
+
cwd,
|
|
64
|
+
env: {
|
|
65
|
+
...process.env,
|
|
66
|
+
HOME: homeDir,
|
|
67
|
+
},
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (completed.status !== 0) {
|
|
72
|
+
throw new Error(completed.stderr || `Preamble command failed with exit code ${completed.status}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
result: JSON.parse(completed.stdout),
|
|
77
|
+
stderr: completed.stderr,
|
|
78
|
+
homeDir,
|
|
79
|
+
cwd,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
while (createdDirs.length > 0) {
|
|
85
|
+
const dir = createdDirs.pop();
|
|
86
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('routing-preamble integration', () => {
|
|
91
|
+
it('uses defaults when no config files exist', () => {
|
|
92
|
+
expect(runPreamble({ commandName: 'build' }).result).toEqual({
|
|
93
|
+
models: ['claude'],
|
|
94
|
+
strategy: 'single',
|
|
95
|
+
source: 'shipped-defaults',
|
|
96
|
+
warnings: [],
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('uses a personal config model override', () => {
|
|
101
|
+
expect(
|
|
102
|
+
runPreamble({
|
|
103
|
+
commandName: 'build',
|
|
104
|
+
personalConfig: {
|
|
105
|
+
task_routing: {
|
|
106
|
+
build: { model: 'codex' },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
}).result,
|
|
110
|
+
).toEqual({
|
|
111
|
+
models: ['codex'],
|
|
112
|
+
strategy: 'single',
|
|
113
|
+
source: 'personal-config',
|
|
114
|
+
warnings: [expect.stringMatching(/models/i)],
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('uses a personal config models array as-is', () => {
|
|
119
|
+
expect(
|
|
120
|
+
runPreamble({
|
|
121
|
+
commandName: 'review',
|
|
122
|
+
personalConfig: {
|
|
123
|
+
task_routing: {
|
|
124
|
+
review: { models: ['codex', 'claude'] },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
}).result,
|
|
128
|
+
).toEqual({
|
|
129
|
+
models: ['codex', 'claude'],
|
|
130
|
+
strategy: 'single',
|
|
131
|
+
source: 'personal-config',
|
|
132
|
+
warnings: [],
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('lets a project override win over personal routing', () => {
|
|
137
|
+
expect(
|
|
138
|
+
runPreamble({
|
|
139
|
+
commandName: 'docs',
|
|
140
|
+
personalConfig: {
|
|
141
|
+
task_routing: {
|
|
142
|
+
docs: { model: 'codex', strategy: 'parallel' },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
projectConfig: {
|
|
146
|
+
task_routing_override: {
|
|
147
|
+
docs: { model: 'claude' },
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}).result,
|
|
151
|
+
).toEqual({
|
|
152
|
+
models: ['claude'],
|
|
153
|
+
strategy: 'parallel',
|
|
154
|
+
source: 'project-override',
|
|
155
|
+
warnings: [expect.stringMatching(/models/i), expect.stringMatching(/models/i)],
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('uses the project override when both config files are present', () => {
|
|
160
|
+
expect(
|
|
161
|
+
runPreamble({
|
|
162
|
+
commandName: 'test',
|
|
163
|
+
personalConfig: {
|
|
164
|
+
task_routing: {
|
|
165
|
+
test: { models: ['codex', 'claude'], strategy: 'parallel' },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
projectConfig: {
|
|
169
|
+
task_routing_override: {
|
|
170
|
+
test: { models: ['claude'], strategy: 'single' },
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
}).result,
|
|
174
|
+
).toEqual({
|
|
175
|
+
models: ['claude'],
|
|
176
|
+
strategy: 'single',
|
|
177
|
+
source: 'project-override',
|
|
178
|
+
warnings: [],
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('lets a flag override win over project and personal config', () => {
|
|
183
|
+
expect(
|
|
184
|
+
runPreamble({
|
|
185
|
+
commandName: 'build',
|
|
186
|
+
flagModel: 'local-model',
|
|
187
|
+
personalConfig: {
|
|
188
|
+
task_routing: {
|
|
189
|
+
build: { model: 'codex', strategy: 'parallel' },
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
projectConfig: {
|
|
193
|
+
task_routing_override: {
|
|
194
|
+
build: { model: 'claude', strategy: 'parallel' },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
}).result,
|
|
198
|
+
).toEqual({
|
|
199
|
+
models: ['local-model'],
|
|
200
|
+
strategy: 'single',
|
|
201
|
+
source: 'flag-override',
|
|
202
|
+
warnings: [expect.stringMatching(/models/i), expect.stringMatching(/models/i)],
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('passes through model providers from personal config', () => {
|
|
207
|
+
const providers = {
|
|
208
|
+
claude: { type: 'cli', command: 'claude' },
|
|
209
|
+
codex: { type: 'cli', command: 'codex' },
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
expect(
|
|
213
|
+
runPreamble({
|
|
214
|
+
commandName: 'build',
|
|
215
|
+
personalConfig: {
|
|
216
|
+
task_routing: {
|
|
217
|
+
build: { model: 'codex' },
|
|
218
|
+
},
|
|
219
|
+
model_providers: providers,
|
|
220
|
+
},
|
|
221
|
+
}).result,
|
|
222
|
+
).toEqual({
|
|
223
|
+
models: ['codex'],
|
|
224
|
+
strategy: 'single',
|
|
225
|
+
source: 'personal-config',
|
|
226
|
+
providers,
|
|
227
|
+
warnings: [expect.stringMatching(/models/i)],
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('falls back to defaults for malformed personal config JSON', () => {
|
|
232
|
+
const { result, stderr, homeDir } = runPreamble({
|
|
233
|
+
commandName: 'build',
|
|
234
|
+
personalConfigRaw: '{not-valid-json',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(result).toEqual({
|
|
238
|
+
models: ['claude'],
|
|
239
|
+
strategy: 'single',
|
|
240
|
+
source: 'shipped-defaults',
|
|
241
|
+
warnings: [expect.stringContaining(path.join(homeDir, '.tlc', 'config.json'))],
|
|
242
|
+
});
|
|
243
|
+
expect(stderr).toContain(path.join(homeDir, '.tlc', 'config.json'));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('falls back to defaults for malformed project config JSON', () => {
|
|
247
|
+
const { result, stderr, cwd } = runPreamble({
|
|
248
|
+
commandName: 'build',
|
|
249
|
+
projectConfigRaw: '{broken-json',
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(result).toEqual({
|
|
253
|
+
models: ['claude'],
|
|
254
|
+
strategy: 'single',
|
|
255
|
+
source: 'shipped-defaults',
|
|
256
|
+
warnings: [expect.stringContaining(path.join(cwd, '.tlc.json'))],
|
|
257
|
+
});
|
|
258
|
+
expect(stderr).toContain(path.join(cwd, '.tlc.json'));
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('still uses personal config when .tlc.json is missing in cwd', () => {
|
|
262
|
+
expect(
|
|
263
|
+
runPreamble({
|
|
264
|
+
commandName: 'quick',
|
|
265
|
+
personalConfig: {
|
|
266
|
+
task_routing: {
|
|
267
|
+
quick: { model: 'codex' },
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
projectDirName: 'cwd-without-project-config',
|
|
271
|
+
}).result,
|
|
272
|
+
).toEqual({
|
|
273
|
+
models: ['codex'],
|
|
274
|
+
strategy: 'single',
|
|
275
|
+
source: 'personal-config',
|
|
276
|
+
warnings: [expect.stringMatching(/models/i)],
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('preserves a parallel strategy from config', () => {
|
|
281
|
+
expect(
|
|
282
|
+
runPreamble({
|
|
283
|
+
commandName: 'plan',
|
|
284
|
+
personalConfig: {
|
|
285
|
+
task_routing: {
|
|
286
|
+
plan: { model: 'codex', strategy: 'parallel' },
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
}).result,
|
|
290
|
+
).toEqual({
|
|
291
|
+
models: ['codex'],
|
|
292
|
+
strategy: 'parallel',
|
|
293
|
+
source: 'personal-config',
|
|
294
|
+
warnings: [expect.stringMatching(/models/i)],
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('prefers models over model in the same entry', () => {
|
|
299
|
+
expect(
|
|
300
|
+
runPreamble({
|
|
301
|
+
commandName: 'build',
|
|
302
|
+
personalConfig: {
|
|
303
|
+
task_routing: {
|
|
304
|
+
build: {
|
|
305
|
+
model: 'claude',
|
|
306
|
+
models: ['codex', 'claude'],
|
|
307
|
+
strategy: 'parallel',
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
}).result,
|
|
312
|
+
).toEqual({
|
|
313
|
+
models: ['codex', 'claude'],
|
|
314
|
+
strategy: 'parallel',
|
|
315
|
+
source: 'personal-config',
|
|
316
|
+
warnings: [],
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -13,28 +13,40 @@ function buildProgram(commandName) {
|
|
|
13
13
|
"const fs = require('fs');",
|
|
14
14
|
"const path = require('path');",
|
|
15
15
|
"const os = require('os');",
|
|
16
|
-
"function readJson(filePath, fileSystem) {",
|
|
16
|
+
"function readJson(filePath, fileSystem, label) {",
|
|
17
17
|
" try {",
|
|
18
|
-
" return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));",
|
|
19
|
-
" } catch {",
|
|
20
|
-
"
|
|
18
|
+
" return { data: JSON.parse(fileSystem.readFileSync(filePath, 'utf8')), warning: null };",
|
|
19
|
+
" } catch (error) {",
|
|
20
|
+
" if (error && error.code === 'ENOENT') {",
|
|
21
|
+
" return { data: null, warning: null };",
|
|
22
|
+
" }",
|
|
23
|
+
" return { data: null, warning: 'Failed to parse ' + label + ' routing config at ' + filePath + ': ' + error.message };",
|
|
21
24
|
" }",
|
|
22
25
|
"}",
|
|
23
26
|
"function loadPersonalConfig(options) {",
|
|
24
27
|
" const configPath = path.join(options.homeDir, '.tlc', 'config.json');",
|
|
25
|
-
"
|
|
28
|
+
" const result = readJson(configPath, options.fs, 'personal');",
|
|
29
|
+
" return { config: result.data, warning: result.warning };",
|
|
26
30
|
"}",
|
|
27
31
|
"function loadProjectOverride(options) {",
|
|
28
32
|
" const configPath = path.join(options.projectDir, '.tlc.json');",
|
|
29
|
-
" const
|
|
30
|
-
" return
|
|
33
|
+
" const result = readJson(configPath, options.fs, 'project');",
|
|
34
|
+
" return {",
|
|
35
|
+
" override: result.data && result.data.task_routing_override ? result.data.task_routing_override : null,",
|
|
36
|
+
" warning: result.warning,",
|
|
37
|
+
" };",
|
|
31
38
|
"}",
|
|
32
39
|
"function resolveRouting(options) {",
|
|
33
40
|
" let models = ['claude'];",
|
|
34
41
|
" let strategy = 'single';",
|
|
35
42
|
" let source = 'shipped-defaults';",
|
|
36
43
|
" let providers;",
|
|
37
|
-
" const
|
|
44
|
+
" const warnings = [];",
|
|
45
|
+
" const personalResult = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });",
|
|
46
|
+
" const personal = personalResult.config;",
|
|
47
|
+
" if (personalResult.warning) {",
|
|
48
|
+
" warnings.push(personalResult.warning);",
|
|
49
|
+
" }",
|
|
38
50
|
" if (personal) {",
|
|
39
51
|
" if (personal.model_providers) {",
|
|
40
52
|
" providers = personal.model_providers;",
|
|
@@ -45,6 +57,7 @@ function buildProgram(commandName) {
|
|
|
45
57
|
" models = personalRouting.models.slice();",
|
|
46
58
|
" } else if (typeof personalRouting.model === 'string') {",
|
|
47
59
|
" models = [personalRouting.model];",
|
|
60
|
+
" warnings.push('Deprecated routing config for command \"' + options.command + '\" in personal config: use \"models\" (array) instead of \"model\" (string).');",
|
|
48
61
|
" }",
|
|
49
62
|
" if (personalRouting.strategy) {",
|
|
50
63
|
" strategy = personalRouting.strategy;",
|
|
@@ -52,7 +65,11 @@ function buildProgram(commandName) {
|
|
|
52
65
|
" source = 'personal-config';",
|
|
53
66
|
" }",
|
|
54
67
|
" }",
|
|
55
|
-
" const
|
|
68
|
+
" const projectResult = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });",
|
|
69
|
+
" const projectOverride = projectResult.override;",
|
|
70
|
+
" if (projectResult.warning) {",
|
|
71
|
+
" warnings.push(projectResult.warning);",
|
|
72
|
+
" }",
|
|
56
73
|
" if (projectOverride) {",
|
|
57
74
|
" const overrideEntry = projectOverride[options.command];",
|
|
58
75
|
" if (overrideEntry) {",
|
|
@@ -60,11 +77,12 @@ function buildProgram(commandName) {
|
|
|
60
77
|
" models = overrideEntry.models.slice();",
|
|
61
78
|
" } else if (typeof overrideEntry.model === 'string') {",
|
|
62
79
|
" models = [overrideEntry.model];",
|
|
80
|
+
" warnings.push('Deprecated routing config for command \"' + options.command + '\" in project override: use \"models\" (array) instead of \"model\" (string).');",
|
|
63
81
|
" }",
|
|
64
82
|
" if (overrideEntry.strategy) {",
|
|
65
83
|
" strategy = overrideEntry.strategy;",
|
|
66
84
|
" }",
|
|
67
|
-
|
|
85
|
+
" source = 'project-override';",
|
|
68
86
|
" }",
|
|
69
87
|
" }",
|
|
70
88
|
" if (options.flagModel) {",
|
|
@@ -72,13 +90,18 @@ function buildProgram(commandName) {
|
|
|
72
90
|
" strategy = 'single';",
|
|
73
91
|
" source = 'flag-override';",
|
|
74
92
|
" }",
|
|
75
|
-
" const result = { models, strategy, source };",
|
|
93
|
+
" const result = { models, strategy, source, warnings };",
|
|
76
94
|
" if (providers) {",
|
|
77
95
|
" result.providers = providers;",
|
|
78
96
|
" }",
|
|
79
97
|
" return result;",
|
|
80
98
|
"}",
|
|
81
99
|
`const result = resolveRouting({ command: ${serializedCommand}, flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });`,
|
|
100
|
+
"if (Array.isArray(result.warnings) && result.warnings.length > 0) {",
|
|
101
|
+
" for (const warning of result.warnings) {",
|
|
102
|
+
" process.stderr.write(warning + '\\n');",
|
|
103
|
+
" }",
|
|
104
|
+
"}",
|
|
82
105
|
'process.stdout.write(JSON.stringify(result));',
|
|
83
106
|
].join('\n');
|
|
84
107
|
}
|
|
@@ -50,6 +50,7 @@ describe('routing-preamble', () => {
|
|
|
50
50
|
models: ['claude'],
|
|
51
51
|
strategy: 'single',
|
|
52
52
|
source: 'shipped-defaults',
|
|
53
|
+
warnings: [],
|
|
53
54
|
});
|
|
54
55
|
});
|
|
55
56
|
|
|
@@ -67,6 +68,7 @@ describe('routing-preamble', () => {
|
|
|
67
68
|
models: ['codex', 'claude'],
|
|
68
69
|
strategy: 'parallel',
|
|
69
70
|
source: 'personal-config',
|
|
71
|
+
warnings: [],
|
|
70
72
|
});
|
|
71
73
|
});
|
|
72
74
|
|
|
@@ -89,6 +91,7 @@ describe('routing-preamble', () => {
|
|
|
89
91
|
models: ['gemini'],
|
|
90
92
|
strategy: 'single',
|
|
91
93
|
source: 'project-override',
|
|
94
|
+
warnings: [],
|
|
92
95
|
});
|
|
93
96
|
});
|
|
94
97
|
|
|
@@ -106,6 +109,7 @@ describe('routing-preamble', () => {
|
|
|
106
109
|
models: ['codex'],
|
|
107
110
|
strategy: 'single',
|
|
108
111
|
source: 'personal-config',
|
|
112
|
+
warnings: [expect.stringMatching(/models/i)],
|
|
109
113
|
});
|
|
110
114
|
});
|
|
111
115
|
|
|
@@ -123,6 +127,7 @@ describe('routing-preamble', () => {
|
|
|
123
127
|
models: ['codex', 'claude'],
|
|
124
128
|
strategy: 'parallel',
|
|
125
129
|
source: 'personal-config',
|
|
130
|
+
warnings: [],
|
|
126
131
|
});
|
|
127
132
|
});
|
|
128
133
|
|
|
@@ -147,6 +152,7 @@ describe('routing-preamble', () => {
|
|
|
147
152
|
strategy: 'single',
|
|
148
153
|
source: 'personal-config',
|
|
149
154
|
providers,
|
|
155
|
+
warnings: [],
|
|
150
156
|
});
|
|
151
157
|
});
|
|
152
158
|
|
|
@@ -170,6 +176,7 @@ describe('routing-preamble', () => {
|
|
|
170
176
|
models: ['local-model'],
|
|
171
177
|
strategy: 'single',
|
|
172
178
|
source: 'flag-override',
|
|
179
|
+
warnings: [],
|
|
173
180
|
});
|
|
174
181
|
});
|
|
175
182
|
|
|
@@ -192,6 +199,7 @@ describe('routing-preamble', () => {
|
|
|
192
199
|
models: ['claude'],
|
|
193
200
|
strategy: 'single',
|
|
194
201
|
source: 'shipped-defaults',
|
|
202
|
+
warnings: [expect.stringContaining(path.join(homeDir, '.tlc', 'config.json'))],
|
|
195
203
|
});
|
|
196
204
|
});
|
|
197
205
|
|
|
@@ -213,6 +221,7 @@ describe('routing-preamble', () => {
|
|
|
213
221
|
models: ['claude'],
|
|
214
222
|
strategy: 'single',
|
|
215
223
|
source: 'shipped-defaults',
|
|
224
|
+
warnings: [expect.stringContaining(path.join(cwd, '.tlc.json'))],
|
|
216
225
|
});
|
|
217
226
|
});
|
|
218
227
|
|
|
@@ -240,6 +249,7 @@ describe('routing-preamble', () => {
|
|
|
240
249
|
models: ['codex'],
|
|
241
250
|
strategy: 'single',
|
|
242
251
|
source: 'project-override',
|
|
252
|
+
warnings: [expect.stringMatching(/models/i)],
|
|
243
253
|
});
|
|
244
254
|
});
|
|
245
255
|
|
|
@@ -250,6 +260,7 @@ describe('routing-preamble', () => {
|
|
|
250
260
|
models: ['claude'],
|
|
251
261
|
strategy: 'single',
|
|
252
262
|
source: 'shipped-defaults',
|
|
263
|
+
warnings: [],
|
|
253
264
|
});
|
|
254
265
|
});
|
|
255
266
|
});
|
|
@@ -35,15 +35,21 @@ for (const cmd of ROUTABLE_COMMANDS) {
|
|
|
35
35
|
* @param {object} options
|
|
36
36
|
* @param {string} options.homeDir - Home directory path
|
|
37
37
|
* @param {object} options.fs - Filesystem module (must have readFileSync)
|
|
38
|
-
* @returns {object|null} Parsed config
|
|
38
|
+
* @returns {{ config: object|null, warning: string|null }} Parsed config and optional warning
|
|
39
39
|
*/
|
|
40
40
|
function loadPersonalConfig({ homeDir, fs }) {
|
|
41
|
+
const configPath = path.join(homeDir, '.tlc', 'config.json');
|
|
41
42
|
try {
|
|
42
|
-
const configPath = path.join(homeDir, '.tlc', 'config.json');
|
|
43
43
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
44
|
-
return JSON.parse(content);
|
|
45
|
-
} catch {
|
|
46
|
-
|
|
44
|
+
return { config: JSON.parse(content), warning: null };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error && error.code === 'ENOENT') {
|
|
47
|
+
return { config: null, warning: null };
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
config: null,
|
|
51
|
+
warning: `Failed to parse personal routing config at ${configPath}: ${error.message}`,
|
|
52
|
+
};
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
|
|
@@ -52,16 +58,22 @@ function loadPersonalConfig({ homeDir, fs }) {
|
|
|
52
58
|
* @param {object} options
|
|
53
59
|
* @param {string} options.projectDir - Project directory path
|
|
54
60
|
* @param {object} options.fs - Filesystem module (must have readFileSync)
|
|
55
|
-
* @returns {object|null} The task_routing_override section
|
|
61
|
+
* @returns {{ override: object|null, warning: string|null }} The task_routing_override section and optional warning
|
|
56
62
|
*/
|
|
57
63
|
function loadProjectOverride({ projectDir, fs }) {
|
|
64
|
+
const configPath = path.join(projectDir, '.tlc.json');
|
|
58
65
|
try {
|
|
59
|
-
const configPath = path.join(projectDir, '.tlc.json');
|
|
60
66
|
const content = fs.readFileSync(configPath, 'utf-8');
|
|
61
67
|
const data = JSON.parse(content);
|
|
62
|
-
return data.task_routing_override || null;
|
|
63
|
-
} catch {
|
|
64
|
-
|
|
68
|
+
return { override: data.task_routing_override || null, warning: null };
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error && error.code === 'ENOENT') {
|
|
71
|
+
return { override: null, warning: null };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
override: null,
|
|
75
|
+
warning: `Failed to parse project routing config at ${configPath}: ${error.message}`,
|
|
76
|
+
};
|
|
65
77
|
}
|
|
66
78
|
}
|
|
67
79
|
|
|
@@ -74,7 +86,7 @@ function loadProjectOverride({ projectDir, fs }) {
|
|
|
74
86
|
* @param {string} options.projectDir - Project directory path
|
|
75
87
|
* @param {string} options.homeDir - Home directory path
|
|
76
88
|
* @param {object} options.fs - Filesystem module (must have readFileSync)
|
|
77
|
-
* @returns {{ models: string[], strategy: string, source: string, providers?: object }}
|
|
89
|
+
* @returns {{ models: string[], strategy: string, source: string, providers?: object, warnings: string[] }}
|
|
78
90
|
*/
|
|
79
91
|
function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDir = require('os').homedir(), fs = require('fs') }) {
|
|
80
92
|
// Start with shipped defaults
|
|
@@ -83,9 +95,13 @@ function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDi
|
|
|
83
95
|
let strategy = defaultEntry.strategy;
|
|
84
96
|
let source = 'shipped-defaults';
|
|
85
97
|
let providers;
|
|
98
|
+
const warnings = [];
|
|
86
99
|
|
|
87
100
|
// Layer 1: personal config (~/.tlc/config.json)
|
|
88
|
-
const personal = loadPersonalConfig({ homeDir, fs });
|
|
101
|
+
const { config: personal, warning: personalWarning } = loadPersonalConfig({ homeDir, fs });
|
|
102
|
+
if (personalWarning) {
|
|
103
|
+
warnings.push(personalWarning);
|
|
104
|
+
}
|
|
89
105
|
if (personal) {
|
|
90
106
|
// Capture model_providers if present
|
|
91
107
|
if (personal.model_providers) {
|
|
@@ -98,6 +114,7 @@ function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDi
|
|
|
98
114
|
models = [...personalRouting.models];
|
|
99
115
|
} else if (personalRouting.model) {
|
|
100
116
|
models = [personalRouting.model];
|
|
117
|
+
warnings.push(`Deprecated routing config for command "${command}" in personal config: use "models" (array) instead of "model" (string).`);
|
|
101
118
|
}
|
|
102
119
|
if (personalRouting.strategy) {
|
|
103
120
|
strategy = personalRouting.strategy;
|
|
@@ -107,7 +124,10 @@ function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDi
|
|
|
107
124
|
}
|
|
108
125
|
|
|
109
126
|
// Layer 2: project override (.tlc.json -> task_routing_override)
|
|
110
|
-
const projectOverride = loadProjectOverride({ projectDir, fs });
|
|
127
|
+
const { override: projectOverride, warning: projectWarning } = loadProjectOverride({ projectDir, fs });
|
|
128
|
+
if (projectWarning) {
|
|
129
|
+
warnings.push(projectWarning);
|
|
130
|
+
}
|
|
111
131
|
if (projectOverride) {
|
|
112
132
|
const overrideEntry = projectOverride[command];
|
|
113
133
|
if (overrideEntry) {
|
|
@@ -115,6 +135,7 @@ function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDi
|
|
|
115
135
|
models = [...overrideEntry.models];
|
|
116
136
|
} else if (overrideEntry.model) {
|
|
117
137
|
models = [overrideEntry.model];
|
|
138
|
+
warnings.push(`Deprecated routing config for command "${command}" in project override: use "models" (array) instead of "model" (string).`);
|
|
118
139
|
}
|
|
119
140
|
if (overrideEntry.strategy) {
|
|
120
141
|
strategy = overrideEntry.strategy;
|
|
@@ -130,7 +151,7 @@ function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDi
|
|
|
130
151
|
source = 'flag-override';
|
|
131
152
|
}
|
|
132
153
|
|
|
133
|
-
const result = { models, strategy, source };
|
|
154
|
+
const result = { models, strategy, source, warnings };
|
|
134
155
|
if (providers) {
|
|
135
156
|
result.providers = providers;
|
|
136
157
|
}
|