tlc-claude-code 2.4.0 → 2.4.2
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 +70 -6
- package/.claude/commands/tlc/build.md +70 -6
- package/.claude/commands/tlc/coverage.md +70 -6
- package/.claude/commands/tlc/discuss.md +70 -6
- package/.claude/commands/tlc/docs.md +70 -6
- package/.claude/commands/tlc/edge-cases.md +70 -6
- package/.claude/commands/tlc/plan.md +70 -6
- package/.claude/commands/tlc/quick.md +70 -6
- package/.claude/commands/tlc/review.md +70 -6
- package/package.json +1 -1
- package/server/lib/routing-preamble.js +93 -0
- package/server/lib/routing-preamble.test.js +255 -0
- package/server/lib/task-router-config.js +4 -0
- package/server/lib/task-router-config.test.js +65 -0
|
@@ -10,12 +10,76 @@ This command supports multi-model routing via `~/.tlc/config.json`.
|
|
|
10
10
|
|
|
11
11
|
1. Read routing config:
|
|
12
12
|
```bash
|
|
13
|
-
node -e "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
node -e "const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
function readJson(filePath, fileSystem) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function loadPersonalConfig(options) {
|
|
24
|
+
const configPath = path.join(options.homeDir, '.tlc', 'config.json');
|
|
25
|
+
return readJson(configPath, options.fs);
|
|
26
|
+
}
|
|
27
|
+
function loadProjectOverride(options) {
|
|
28
|
+
const configPath = path.join(options.projectDir, '.tlc.json');
|
|
29
|
+
const data = readJson(configPath, options.fs);
|
|
30
|
+
return data && data.task_routing_override ? data.task_routing_override : null;
|
|
31
|
+
}
|
|
32
|
+
function resolveRouting(options) {
|
|
33
|
+
let models = ['claude'];
|
|
34
|
+
let strategy = 'single';
|
|
35
|
+
let source = 'shipped-defaults';
|
|
36
|
+
let providers;
|
|
37
|
+
const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });
|
|
38
|
+
if (personal) {
|
|
39
|
+
if (personal.model_providers) {
|
|
40
|
+
providers = personal.model_providers;
|
|
41
|
+
}
|
|
42
|
+
const personalRouting = personal.task_routing && personal.task_routing[options.command];
|
|
43
|
+
if (personalRouting) {
|
|
44
|
+
if (Array.isArray(personalRouting.models)) {
|
|
45
|
+
models = personalRouting.models.slice();
|
|
46
|
+
} else if (typeof personalRouting.model === 'string') {
|
|
47
|
+
models = [personalRouting.model];
|
|
48
|
+
}
|
|
49
|
+
if (personalRouting.strategy) {
|
|
50
|
+
strategy = personalRouting.strategy;
|
|
51
|
+
}
|
|
52
|
+
source = 'personal-config';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });
|
|
56
|
+
if (projectOverride) {
|
|
57
|
+
const overrideEntry = projectOverride[options.command];
|
|
58
|
+
if (overrideEntry) {
|
|
59
|
+
if (Array.isArray(overrideEntry.models)) {
|
|
60
|
+
models = overrideEntry.models.slice();
|
|
61
|
+
} else if (typeof overrideEntry.model === 'string') {
|
|
62
|
+
models = [overrideEntry.model];
|
|
63
|
+
}
|
|
64
|
+
if (overrideEntry.strategy) {
|
|
65
|
+
strategy = overrideEntry.strategy;
|
|
66
|
+
}
|
|
67
|
+
source = 'project-override';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.flagModel) {
|
|
71
|
+
models = [options.flagModel];
|
|
72
|
+
strategy = 'single';
|
|
73
|
+
source = 'flag-override';
|
|
74
|
+
}
|
|
75
|
+
const result = { models, strategy, source };
|
|
76
|
+
if (providers) {
|
|
77
|
+
result.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const result = resolveRouting({ command: \"autofix\", flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });
|
|
82
|
+
process.stdout.write(JSON.stringify(result));" 2>/dev/null
|
|
19
83
|
```
|
|
20
84
|
|
|
21
85
|
2. If `models[0]` is NOT `claude` (i.e., routed to external model):
|
|
@@ -10,12 +10,76 @@ This command supports multi-model routing via `~/.tlc/config.json`.
|
|
|
10
10
|
|
|
11
11
|
1. Read routing config:
|
|
12
12
|
```bash
|
|
13
|
-
node -e "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
node -e "const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
function readJson(filePath, fileSystem) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function loadPersonalConfig(options) {
|
|
24
|
+
const configPath = path.join(options.homeDir, '.tlc', 'config.json');
|
|
25
|
+
return readJson(configPath, options.fs);
|
|
26
|
+
}
|
|
27
|
+
function loadProjectOverride(options) {
|
|
28
|
+
const configPath = path.join(options.projectDir, '.tlc.json');
|
|
29
|
+
const data = readJson(configPath, options.fs);
|
|
30
|
+
return data && data.task_routing_override ? data.task_routing_override : null;
|
|
31
|
+
}
|
|
32
|
+
function resolveRouting(options) {
|
|
33
|
+
let models = ['claude'];
|
|
34
|
+
let strategy = 'single';
|
|
35
|
+
let source = 'shipped-defaults';
|
|
36
|
+
let providers;
|
|
37
|
+
const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });
|
|
38
|
+
if (personal) {
|
|
39
|
+
if (personal.model_providers) {
|
|
40
|
+
providers = personal.model_providers;
|
|
41
|
+
}
|
|
42
|
+
const personalRouting = personal.task_routing && personal.task_routing[options.command];
|
|
43
|
+
if (personalRouting) {
|
|
44
|
+
if (Array.isArray(personalRouting.models)) {
|
|
45
|
+
models = personalRouting.models.slice();
|
|
46
|
+
} else if (typeof personalRouting.model === 'string') {
|
|
47
|
+
models = [personalRouting.model];
|
|
48
|
+
}
|
|
49
|
+
if (personalRouting.strategy) {
|
|
50
|
+
strategy = personalRouting.strategy;
|
|
51
|
+
}
|
|
52
|
+
source = 'personal-config';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });
|
|
56
|
+
if (projectOverride) {
|
|
57
|
+
const overrideEntry = projectOverride[options.command];
|
|
58
|
+
if (overrideEntry) {
|
|
59
|
+
if (Array.isArray(overrideEntry.models)) {
|
|
60
|
+
models = overrideEntry.models.slice();
|
|
61
|
+
} else if (typeof overrideEntry.model === 'string') {
|
|
62
|
+
models = [overrideEntry.model];
|
|
63
|
+
}
|
|
64
|
+
if (overrideEntry.strategy) {
|
|
65
|
+
strategy = overrideEntry.strategy;
|
|
66
|
+
}
|
|
67
|
+
source = 'project-override';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.flagModel) {
|
|
71
|
+
models = [options.flagModel];
|
|
72
|
+
strategy = 'single';
|
|
73
|
+
source = 'flag-override';
|
|
74
|
+
}
|
|
75
|
+
const result = { models, strategy, source };
|
|
76
|
+
if (providers) {
|
|
77
|
+
result.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const result = resolveRouting({ command: \"build\", flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });
|
|
82
|
+
process.stdout.write(JSON.stringify(result));" 2>/dev/null
|
|
19
83
|
```
|
|
20
84
|
|
|
21
85
|
2. If `models[0]` is NOT `claude` (i.e., routed to external model):
|
|
@@ -10,12 +10,76 @@ This command supports multi-model routing via `~/.tlc/config.json`.
|
|
|
10
10
|
|
|
11
11
|
1. Read routing config:
|
|
12
12
|
```bash
|
|
13
|
-
node -e "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
node -e "const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
function readJson(filePath, fileSystem) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function loadPersonalConfig(options) {
|
|
24
|
+
const configPath = path.join(options.homeDir, '.tlc', 'config.json');
|
|
25
|
+
return readJson(configPath, options.fs);
|
|
26
|
+
}
|
|
27
|
+
function loadProjectOverride(options) {
|
|
28
|
+
const configPath = path.join(options.projectDir, '.tlc.json');
|
|
29
|
+
const data = readJson(configPath, options.fs);
|
|
30
|
+
return data && data.task_routing_override ? data.task_routing_override : null;
|
|
31
|
+
}
|
|
32
|
+
function resolveRouting(options) {
|
|
33
|
+
let models = ['claude'];
|
|
34
|
+
let strategy = 'single';
|
|
35
|
+
let source = 'shipped-defaults';
|
|
36
|
+
let providers;
|
|
37
|
+
const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });
|
|
38
|
+
if (personal) {
|
|
39
|
+
if (personal.model_providers) {
|
|
40
|
+
providers = personal.model_providers;
|
|
41
|
+
}
|
|
42
|
+
const personalRouting = personal.task_routing && personal.task_routing[options.command];
|
|
43
|
+
if (personalRouting) {
|
|
44
|
+
if (Array.isArray(personalRouting.models)) {
|
|
45
|
+
models = personalRouting.models.slice();
|
|
46
|
+
} else if (typeof personalRouting.model === 'string') {
|
|
47
|
+
models = [personalRouting.model];
|
|
48
|
+
}
|
|
49
|
+
if (personalRouting.strategy) {
|
|
50
|
+
strategy = personalRouting.strategy;
|
|
51
|
+
}
|
|
52
|
+
source = 'personal-config';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });
|
|
56
|
+
if (projectOverride) {
|
|
57
|
+
const overrideEntry = projectOverride[options.command];
|
|
58
|
+
if (overrideEntry) {
|
|
59
|
+
if (Array.isArray(overrideEntry.models)) {
|
|
60
|
+
models = overrideEntry.models.slice();
|
|
61
|
+
} else if (typeof overrideEntry.model === 'string') {
|
|
62
|
+
models = [overrideEntry.model];
|
|
63
|
+
}
|
|
64
|
+
if (overrideEntry.strategy) {
|
|
65
|
+
strategy = overrideEntry.strategy;
|
|
66
|
+
}
|
|
67
|
+
source = 'project-override';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.flagModel) {
|
|
71
|
+
models = [options.flagModel];
|
|
72
|
+
strategy = 'single';
|
|
73
|
+
source = 'flag-override';
|
|
74
|
+
}
|
|
75
|
+
const result = { models, strategy, source };
|
|
76
|
+
if (providers) {
|
|
77
|
+
result.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const result = resolveRouting({ command: \"coverage\", flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });
|
|
82
|
+
process.stdout.write(JSON.stringify(result));" 2>/dev/null
|
|
19
83
|
```
|
|
20
84
|
|
|
21
85
|
2. If `models[0]` is NOT `claude` (i.e., routed to external model):
|
|
@@ -10,12 +10,76 @@ This command supports multi-model routing via `~/.tlc/config.json`.
|
|
|
10
10
|
|
|
11
11
|
1. Read routing config:
|
|
12
12
|
```bash
|
|
13
|
-
node -e "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
node -e "const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
function readJson(filePath, fileSystem) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function loadPersonalConfig(options) {
|
|
24
|
+
const configPath = path.join(options.homeDir, '.tlc', 'config.json');
|
|
25
|
+
return readJson(configPath, options.fs);
|
|
26
|
+
}
|
|
27
|
+
function loadProjectOverride(options) {
|
|
28
|
+
const configPath = path.join(options.projectDir, '.tlc.json');
|
|
29
|
+
const data = readJson(configPath, options.fs);
|
|
30
|
+
return data && data.task_routing_override ? data.task_routing_override : null;
|
|
31
|
+
}
|
|
32
|
+
function resolveRouting(options) {
|
|
33
|
+
let models = ['claude'];
|
|
34
|
+
let strategy = 'single';
|
|
35
|
+
let source = 'shipped-defaults';
|
|
36
|
+
let providers;
|
|
37
|
+
const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });
|
|
38
|
+
if (personal) {
|
|
39
|
+
if (personal.model_providers) {
|
|
40
|
+
providers = personal.model_providers;
|
|
41
|
+
}
|
|
42
|
+
const personalRouting = personal.task_routing && personal.task_routing[options.command];
|
|
43
|
+
if (personalRouting) {
|
|
44
|
+
if (Array.isArray(personalRouting.models)) {
|
|
45
|
+
models = personalRouting.models.slice();
|
|
46
|
+
} else if (typeof personalRouting.model === 'string') {
|
|
47
|
+
models = [personalRouting.model];
|
|
48
|
+
}
|
|
49
|
+
if (personalRouting.strategy) {
|
|
50
|
+
strategy = personalRouting.strategy;
|
|
51
|
+
}
|
|
52
|
+
source = 'personal-config';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });
|
|
56
|
+
if (projectOverride) {
|
|
57
|
+
const overrideEntry = projectOverride[options.command];
|
|
58
|
+
if (overrideEntry) {
|
|
59
|
+
if (Array.isArray(overrideEntry.models)) {
|
|
60
|
+
models = overrideEntry.models.slice();
|
|
61
|
+
} else if (typeof overrideEntry.model === 'string') {
|
|
62
|
+
models = [overrideEntry.model];
|
|
63
|
+
}
|
|
64
|
+
if (overrideEntry.strategy) {
|
|
65
|
+
strategy = overrideEntry.strategy;
|
|
66
|
+
}
|
|
67
|
+
source = 'project-override';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.flagModel) {
|
|
71
|
+
models = [options.flagModel];
|
|
72
|
+
strategy = 'single';
|
|
73
|
+
source = 'flag-override';
|
|
74
|
+
}
|
|
75
|
+
const result = { models, strategy, source };
|
|
76
|
+
if (providers) {
|
|
77
|
+
result.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const result = resolveRouting({ command: \"discuss\", flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });
|
|
82
|
+
process.stdout.write(JSON.stringify(result));" 2>/dev/null
|
|
19
83
|
```
|
|
20
84
|
|
|
21
85
|
2. If `models[0]` is NOT `claude` (i.e., routed to external model):
|
|
@@ -10,12 +10,76 @@ This command supports multi-model routing via `~/.tlc/config.json`.
|
|
|
10
10
|
|
|
11
11
|
1. Read routing config:
|
|
12
12
|
```bash
|
|
13
|
-
node -e "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
node -e "const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
function readJson(filePath, fileSystem) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function loadPersonalConfig(options) {
|
|
24
|
+
const configPath = path.join(options.homeDir, '.tlc', 'config.json');
|
|
25
|
+
return readJson(configPath, options.fs);
|
|
26
|
+
}
|
|
27
|
+
function loadProjectOverride(options) {
|
|
28
|
+
const configPath = path.join(options.projectDir, '.tlc.json');
|
|
29
|
+
const data = readJson(configPath, options.fs);
|
|
30
|
+
return data && data.task_routing_override ? data.task_routing_override : null;
|
|
31
|
+
}
|
|
32
|
+
function resolveRouting(options) {
|
|
33
|
+
let models = ['claude'];
|
|
34
|
+
let strategy = 'single';
|
|
35
|
+
let source = 'shipped-defaults';
|
|
36
|
+
let providers;
|
|
37
|
+
const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });
|
|
38
|
+
if (personal) {
|
|
39
|
+
if (personal.model_providers) {
|
|
40
|
+
providers = personal.model_providers;
|
|
41
|
+
}
|
|
42
|
+
const personalRouting = personal.task_routing && personal.task_routing[options.command];
|
|
43
|
+
if (personalRouting) {
|
|
44
|
+
if (Array.isArray(personalRouting.models)) {
|
|
45
|
+
models = personalRouting.models.slice();
|
|
46
|
+
} else if (typeof personalRouting.model === 'string') {
|
|
47
|
+
models = [personalRouting.model];
|
|
48
|
+
}
|
|
49
|
+
if (personalRouting.strategy) {
|
|
50
|
+
strategy = personalRouting.strategy;
|
|
51
|
+
}
|
|
52
|
+
source = 'personal-config';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });
|
|
56
|
+
if (projectOverride) {
|
|
57
|
+
const overrideEntry = projectOverride[options.command];
|
|
58
|
+
if (overrideEntry) {
|
|
59
|
+
if (Array.isArray(overrideEntry.models)) {
|
|
60
|
+
models = overrideEntry.models.slice();
|
|
61
|
+
} else if (typeof overrideEntry.model === 'string') {
|
|
62
|
+
models = [overrideEntry.model];
|
|
63
|
+
}
|
|
64
|
+
if (overrideEntry.strategy) {
|
|
65
|
+
strategy = overrideEntry.strategy;
|
|
66
|
+
}
|
|
67
|
+
source = 'project-override';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.flagModel) {
|
|
71
|
+
models = [options.flagModel];
|
|
72
|
+
strategy = 'single';
|
|
73
|
+
source = 'flag-override';
|
|
74
|
+
}
|
|
75
|
+
const result = { models, strategy, source };
|
|
76
|
+
if (providers) {
|
|
77
|
+
result.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const result = resolveRouting({ command: \"docs\", flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });
|
|
82
|
+
process.stdout.write(JSON.stringify(result));" 2>/dev/null
|
|
19
83
|
```
|
|
20
84
|
|
|
21
85
|
2. If `models[0]` is NOT `claude` (i.e., routed to external model):
|
|
@@ -10,12 +10,76 @@ This command supports multi-model routing via `~/.tlc/config.json`.
|
|
|
10
10
|
|
|
11
11
|
1. Read routing config:
|
|
12
12
|
```bash
|
|
13
|
-
node -e "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
node -e "const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
function readJson(filePath, fileSystem) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function loadPersonalConfig(options) {
|
|
24
|
+
const configPath = path.join(options.homeDir, '.tlc', 'config.json');
|
|
25
|
+
return readJson(configPath, options.fs);
|
|
26
|
+
}
|
|
27
|
+
function loadProjectOverride(options) {
|
|
28
|
+
const configPath = path.join(options.projectDir, '.tlc.json');
|
|
29
|
+
const data = readJson(configPath, options.fs);
|
|
30
|
+
return data && data.task_routing_override ? data.task_routing_override : null;
|
|
31
|
+
}
|
|
32
|
+
function resolveRouting(options) {
|
|
33
|
+
let models = ['claude'];
|
|
34
|
+
let strategy = 'single';
|
|
35
|
+
let source = 'shipped-defaults';
|
|
36
|
+
let providers;
|
|
37
|
+
const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });
|
|
38
|
+
if (personal) {
|
|
39
|
+
if (personal.model_providers) {
|
|
40
|
+
providers = personal.model_providers;
|
|
41
|
+
}
|
|
42
|
+
const personalRouting = personal.task_routing && personal.task_routing[options.command];
|
|
43
|
+
if (personalRouting) {
|
|
44
|
+
if (Array.isArray(personalRouting.models)) {
|
|
45
|
+
models = personalRouting.models.slice();
|
|
46
|
+
} else if (typeof personalRouting.model === 'string') {
|
|
47
|
+
models = [personalRouting.model];
|
|
48
|
+
}
|
|
49
|
+
if (personalRouting.strategy) {
|
|
50
|
+
strategy = personalRouting.strategy;
|
|
51
|
+
}
|
|
52
|
+
source = 'personal-config';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });
|
|
56
|
+
if (projectOverride) {
|
|
57
|
+
const overrideEntry = projectOverride[options.command];
|
|
58
|
+
if (overrideEntry) {
|
|
59
|
+
if (Array.isArray(overrideEntry.models)) {
|
|
60
|
+
models = overrideEntry.models.slice();
|
|
61
|
+
} else if (typeof overrideEntry.model === 'string') {
|
|
62
|
+
models = [overrideEntry.model];
|
|
63
|
+
}
|
|
64
|
+
if (overrideEntry.strategy) {
|
|
65
|
+
strategy = overrideEntry.strategy;
|
|
66
|
+
}
|
|
67
|
+
source = 'project-override';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.flagModel) {
|
|
71
|
+
models = [options.flagModel];
|
|
72
|
+
strategy = 'single';
|
|
73
|
+
source = 'flag-override';
|
|
74
|
+
}
|
|
75
|
+
const result = { models, strategy, source };
|
|
76
|
+
if (providers) {
|
|
77
|
+
result.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const result = resolveRouting({ command: \"edge-cases\", flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });
|
|
82
|
+
process.stdout.write(JSON.stringify(result));" 2>/dev/null
|
|
19
83
|
```
|
|
20
84
|
|
|
21
85
|
2. If `models[0]` is NOT `claude` (i.e., routed to external model):
|
|
@@ -10,12 +10,76 @@ This command supports multi-model routing via `~/.tlc/config.json`.
|
|
|
10
10
|
|
|
11
11
|
1. Read routing config:
|
|
12
12
|
```bash
|
|
13
|
-
node -e "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
node -e "const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
function readJson(filePath, fileSystem) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function loadPersonalConfig(options) {
|
|
24
|
+
const configPath = path.join(options.homeDir, '.tlc', 'config.json');
|
|
25
|
+
return readJson(configPath, options.fs);
|
|
26
|
+
}
|
|
27
|
+
function loadProjectOverride(options) {
|
|
28
|
+
const configPath = path.join(options.projectDir, '.tlc.json');
|
|
29
|
+
const data = readJson(configPath, options.fs);
|
|
30
|
+
return data && data.task_routing_override ? data.task_routing_override : null;
|
|
31
|
+
}
|
|
32
|
+
function resolveRouting(options) {
|
|
33
|
+
let models = ['claude'];
|
|
34
|
+
let strategy = 'single';
|
|
35
|
+
let source = 'shipped-defaults';
|
|
36
|
+
let providers;
|
|
37
|
+
const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });
|
|
38
|
+
if (personal) {
|
|
39
|
+
if (personal.model_providers) {
|
|
40
|
+
providers = personal.model_providers;
|
|
41
|
+
}
|
|
42
|
+
const personalRouting = personal.task_routing && personal.task_routing[options.command];
|
|
43
|
+
if (personalRouting) {
|
|
44
|
+
if (Array.isArray(personalRouting.models)) {
|
|
45
|
+
models = personalRouting.models.slice();
|
|
46
|
+
} else if (typeof personalRouting.model === 'string') {
|
|
47
|
+
models = [personalRouting.model];
|
|
48
|
+
}
|
|
49
|
+
if (personalRouting.strategy) {
|
|
50
|
+
strategy = personalRouting.strategy;
|
|
51
|
+
}
|
|
52
|
+
source = 'personal-config';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });
|
|
56
|
+
if (projectOverride) {
|
|
57
|
+
const overrideEntry = projectOverride[options.command];
|
|
58
|
+
if (overrideEntry) {
|
|
59
|
+
if (Array.isArray(overrideEntry.models)) {
|
|
60
|
+
models = overrideEntry.models.slice();
|
|
61
|
+
} else if (typeof overrideEntry.model === 'string') {
|
|
62
|
+
models = [overrideEntry.model];
|
|
63
|
+
}
|
|
64
|
+
if (overrideEntry.strategy) {
|
|
65
|
+
strategy = overrideEntry.strategy;
|
|
66
|
+
}
|
|
67
|
+
source = 'project-override';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.flagModel) {
|
|
71
|
+
models = [options.flagModel];
|
|
72
|
+
strategy = 'single';
|
|
73
|
+
source = 'flag-override';
|
|
74
|
+
}
|
|
75
|
+
const result = { models, strategy, source };
|
|
76
|
+
if (providers) {
|
|
77
|
+
result.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const result = resolveRouting({ command: \"plan\", flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });
|
|
82
|
+
process.stdout.write(JSON.stringify(result));" 2>/dev/null
|
|
19
83
|
```
|
|
20
84
|
|
|
21
85
|
2. If `models[0]` is NOT `claude` (i.e., routed to external model):
|
|
@@ -10,12 +10,76 @@ This command supports multi-model routing via `~/.tlc/config.json`.
|
|
|
10
10
|
|
|
11
11
|
1. Read routing config:
|
|
12
12
|
```bash
|
|
13
|
-
node -e "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
node -e "const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
function readJson(filePath, fileSystem) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function loadPersonalConfig(options) {
|
|
24
|
+
const configPath = path.join(options.homeDir, '.tlc', 'config.json');
|
|
25
|
+
return readJson(configPath, options.fs);
|
|
26
|
+
}
|
|
27
|
+
function loadProjectOverride(options) {
|
|
28
|
+
const configPath = path.join(options.projectDir, '.tlc.json');
|
|
29
|
+
const data = readJson(configPath, options.fs);
|
|
30
|
+
return data && data.task_routing_override ? data.task_routing_override : null;
|
|
31
|
+
}
|
|
32
|
+
function resolveRouting(options) {
|
|
33
|
+
let models = ['claude'];
|
|
34
|
+
let strategy = 'single';
|
|
35
|
+
let source = 'shipped-defaults';
|
|
36
|
+
let providers;
|
|
37
|
+
const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });
|
|
38
|
+
if (personal) {
|
|
39
|
+
if (personal.model_providers) {
|
|
40
|
+
providers = personal.model_providers;
|
|
41
|
+
}
|
|
42
|
+
const personalRouting = personal.task_routing && personal.task_routing[options.command];
|
|
43
|
+
if (personalRouting) {
|
|
44
|
+
if (Array.isArray(personalRouting.models)) {
|
|
45
|
+
models = personalRouting.models.slice();
|
|
46
|
+
} else if (typeof personalRouting.model === 'string') {
|
|
47
|
+
models = [personalRouting.model];
|
|
48
|
+
}
|
|
49
|
+
if (personalRouting.strategy) {
|
|
50
|
+
strategy = personalRouting.strategy;
|
|
51
|
+
}
|
|
52
|
+
source = 'personal-config';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });
|
|
56
|
+
if (projectOverride) {
|
|
57
|
+
const overrideEntry = projectOverride[options.command];
|
|
58
|
+
if (overrideEntry) {
|
|
59
|
+
if (Array.isArray(overrideEntry.models)) {
|
|
60
|
+
models = overrideEntry.models.slice();
|
|
61
|
+
} else if (typeof overrideEntry.model === 'string') {
|
|
62
|
+
models = [overrideEntry.model];
|
|
63
|
+
}
|
|
64
|
+
if (overrideEntry.strategy) {
|
|
65
|
+
strategy = overrideEntry.strategy;
|
|
66
|
+
}
|
|
67
|
+
source = 'project-override';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.flagModel) {
|
|
71
|
+
models = [options.flagModel];
|
|
72
|
+
strategy = 'single';
|
|
73
|
+
source = 'flag-override';
|
|
74
|
+
}
|
|
75
|
+
const result = { models, strategy, source };
|
|
76
|
+
if (providers) {
|
|
77
|
+
result.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const result = resolveRouting({ command: \"quick\", flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });
|
|
82
|
+
process.stdout.write(JSON.stringify(result));" 2>/dev/null
|
|
19
83
|
```
|
|
20
84
|
|
|
21
85
|
2. If `models[0]` is NOT `claude` (i.e., routed to external model):
|
|
@@ -10,12 +10,76 @@ This command supports multi-model routing via `~/.tlc/config.json`.
|
|
|
10
10
|
|
|
11
11
|
1. Read routing config:
|
|
12
12
|
```bash
|
|
13
|
-
node -e "
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
node -e "const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const os = require('os');
|
|
16
|
+
function readJson(filePath, fileSystem) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function loadPersonalConfig(options) {
|
|
24
|
+
const configPath = path.join(options.homeDir, '.tlc', 'config.json');
|
|
25
|
+
return readJson(configPath, options.fs);
|
|
26
|
+
}
|
|
27
|
+
function loadProjectOverride(options) {
|
|
28
|
+
const configPath = path.join(options.projectDir, '.tlc.json');
|
|
29
|
+
const data = readJson(configPath, options.fs);
|
|
30
|
+
return data && data.task_routing_override ? data.task_routing_override : null;
|
|
31
|
+
}
|
|
32
|
+
function resolveRouting(options) {
|
|
33
|
+
let models = ['claude'];
|
|
34
|
+
let strategy = 'single';
|
|
35
|
+
let source = 'shipped-defaults';
|
|
36
|
+
let providers;
|
|
37
|
+
const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });
|
|
38
|
+
if (personal) {
|
|
39
|
+
if (personal.model_providers) {
|
|
40
|
+
providers = personal.model_providers;
|
|
41
|
+
}
|
|
42
|
+
const personalRouting = personal.task_routing && personal.task_routing[options.command];
|
|
43
|
+
if (personalRouting) {
|
|
44
|
+
if (Array.isArray(personalRouting.models)) {
|
|
45
|
+
models = personalRouting.models.slice();
|
|
46
|
+
} else if (typeof personalRouting.model === 'string') {
|
|
47
|
+
models = [personalRouting.model];
|
|
48
|
+
}
|
|
49
|
+
if (personalRouting.strategy) {
|
|
50
|
+
strategy = personalRouting.strategy;
|
|
51
|
+
}
|
|
52
|
+
source = 'personal-config';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });
|
|
56
|
+
if (projectOverride) {
|
|
57
|
+
const overrideEntry = projectOverride[options.command];
|
|
58
|
+
if (overrideEntry) {
|
|
59
|
+
if (Array.isArray(overrideEntry.models)) {
|
|
60
|
+
models = overrideEntry.models.slice();
|
|
61
|
+
} else if (typeof overrideEntry.model === 'string') {
|
|
62
|
+
models = [overrideEntry.model];
|
|
63
|
+
}
|
|
64
|
+
if (overrideEntry.strategy) {
|
|
65
|
+
strategy = overrideEntry.strategy;
|
|
66
|
+
}
|
|
67
|
+
source = 'project-override';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (options.flagModel) {
|
|
71
|
+
models = [options.flagModel];
|
|
72
|
+
strategy = 'single';
|
|
73
|
+
source = 'flag-override';
|
|
74
|
+
}
|
|
75
|
+
const result = { models, strategy, source };
|
|
76
|
+
if (providers) {
|
|
77
|
+
result.providers = providers;
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const result = resolveRouting({ command: \"review\", flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });
|
|
82
|
+
process.stdout.write(JSON.stringify(result));" 2>/dev/null
|
|
19
83
|
```
|
|
20
84
|
|
|
21
85
|
2. If `models[0]` is NOT `claude` (i.e., routed to external model):
|
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
function escapeForDoubleQuotedShell(value) {
|
|
2
|
+
return value
|
|
3
|
+
.replace(/\\/g, '\\\\')
|
|
4
|
+
.replace(/"/g, '\\"')
|
|
5
|
+
.replace(/\$/g, '\\$')
|
|
6
|
+
.replace(/`/g, '\\`');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function buildProgram(commandName) {
|
|
10
|
+
const serializedCommand = JSON.stringify(commandName);
|
|
11
|
+
|
|
12
|
+
return [
|
|
13
|
+
"const fs = require('fs');",
|
|
14
|
+
"const path = require('path');",
|
|
15
|
+
"const os = require('os');",
|
|
16
|
+
"function readJson(filePath, fileSystem) {",
|
|
17
|
+
" try {",
|
|
18
|
+
" return JSON.parse(fileSystem.readFileSync(filePath, 'utf8'));",
|
|
19
|
+
" } catch {",
|
|
20
|
+
" return null;",
|
|
21
|
+
" }",
|
|
22
|
+
"}",
|
|
23
|
+
"function loadPersonalConfig(options) {",
|
|
24
|
+
" const configPath = path.join(options.homeDir, '.tlc', 'config.json');",
|
|
25
|
+
" return readJson(configPath, options.fs);",
|
|
26
|
+
"}",
|
|
27
|
+
"function loadProjectOverride(options) {",
|
|
28
|
+
" const configPath = path.join(options.projectDir, '.tlc.json');",
|
|
29
|
+
" const data = readJson(configPath, options.fs);",
|
|
30
|
+
" return data && data.task_routing_override ? data.task_routing_override : null;",
|
|
31
|
+
"}",
|
|
32
|
+
"function resolveRouting(options) {",
|
|
33
|
+
" let models = ['claude'];",
|
|
34
|
+
" let strategy = 'single';",
|
|
35
|
+
" let source = 'shipped-defaults';",
|
|
36
|
+
" let providers;",
|
|
37
|
+
" const personal = loadPersonalConfig({ homeDir: options.homeDir, fs: options.fs });",
|
|
38
|
+
" if (personal) {",
|
|
39
|
+
" if (personal.model_providers) {",
|
|
40
|
+
" providers = personal.model_providers;",
|
|
41
|
+
" }",
|
|
42
|
+
" const personalRouting = personal.task_routing && personal.task_routing[options.command];",
|
|
43
|
+
" if (personalRouting) {",
|
|
44
|
+
" if (Array.isArray(personalRouting.models)) {",
|
|
45
|
+
" models = personalRouting.models.slice();",
|
|
46
|
+
" } else if (typeof personalRouting.model === 'string') {",
|
|
47
|
+
" models = [personalRouting.model];",
|
|
48
|
+
" }",
|
|
49
|
+
" if (personalRouting.strategy) {",
|
|
50
|
+
" strategy = personalRouting.strategy;",
|
|
51
|
+
" }",
|
|
52
|
+
" source = 'personal-config';",
|
|
53
|
+
" }",
|
|
54
|
+
" }",
|
|
55
|
+
" const projectOverride = loadProjectOverride({ projectDir: options.projectDir, fs: options.fs });",
|
|
56
|
+
" if (projectOverride) {",
|
|
57
|
+
" const overrideEntry = projectOverride[options.command];",
|
|
58
|
+
" if (overrideEntry) {",
|
|
59
|
+
" if (Array.isArray(overrideEntry.models)) {",
|
|
60
|
+
" models = overrideEntry.models.slice();",
|
|
61
|
+
" } else if (typeof overrideEntry.model === 'string') {",
|
|
62
|
+
" models = [overrideEntry.model];",
|
|
63
|
+
" }",
|
|
64
|
+
" if (overrideEntry.strategy) {",
|
|
65
|
+
" strategy = overrideEntry.strategy;",
|
|
66
|
+
" }",
|
|
67
|
+
" source = 'project-override';",
|
|
68
|
+
" }",
|
|
69
|
+
" }",
|
|
70
|
+
" if (options.flagModel) {",
|
|
71
|
+
" models = [options.flagModel];",
|
|
72
|
+
" strategy = 'single';",
|
|
73
|
+
" source = 'flag-override';",
|
|
74
|
+
" }",
|
|
75
|
+
" const result = { models, strategy, source };",
|
|
76
|
+
" if (providers) {",
|
|
77
|
+
" result.providers = providers;",
|
|
78
|
+
" }",
|
|
79
|
+
" return result;",
|
|
80
|
+
"}",
|
|
81
|
+
`const result = resolveRouting({ command: ${serializedCommand}, flagModel: process.argv[1], projectDir: process.cwd(), homeDir: process.env.HOME || os.homedir(), fs });`,
|
|
82
|
+
'process.stdout.write(JSON.stringify(result));',
|
|
83
|
+
].join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function generatePreamble(commandName) {
|
|
87
|
+
const program = buildProgram(commandName);
|
|
88
|
+
return `node -e "${escapeForDoubleQuotedShell(program)}"`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
generatePreamble,
|
|
93
|
+
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
import routingPreamble from './routing-preamble.js';
|
|
7
|
+
|
|
8
|
+
const { generatePreamble } = routingPreamble;
|
|
9
|
+
|
|
10
|
+
function makeTempDir(prefix) {
|
|
11
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function writeJson(filePath, data) {
|
|
15
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
16
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function runGeneratedScript({ commandName, homeConfig, projectConfig, cwdName = 'routing-project-', flagModel } = {}) {
|
|
20
|
+
const homeDir = makeTempDir('routing-home-');
|
|
21
|
+
const cwd = makeTempDir(cwdName);
|
|
22
|
+
|
|
23
|
+
if (homeConfig !== undefined) {
|
|
24
|
+
writeJson(path.join(homeDir, '.tlc', 'config.json'), homeConfig);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (projectConfig !== undefined) {
|
|
28
|
+
writeJson(path.join(cwd, '.tlc.json'), projectConfig);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const script = generatePreamble(commandName);
|
|
32
|
+
const argv = ['-lc', flagModel ? `${script} -- ${JSON.stringify(flagModel)}` : script];
|
|
33
|
+
const output = execFileSync('bash', argv, {
|
|
34
|
+
cwd,
|
|
35
|
+
env: {
|
|
36
|
+
...process.env,
|
|
37
|
+
HOME: homeDir,
|
|
38
|
+
},
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return JSON.parse(output);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('routing-preamble', () => {
|
|
46
|
+
it('returns shipped defaults when no config files exist', () => {
|
|
47
|
+
const result = runGeneratedScript({ commandName: 'build' });
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
models: ['claude'],
|
|
51
|
+
strategy: 'single',
|
|
52
|
+
source: 'shipped-defaults',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reads personal routing from ~/.tlc/config.json', () => {
|
|
57
|
+
const result = runGeneratedScript({
|
|
58
|
+
commandName: 'review',
|
|
59
|
+
homeConfig: {
|
|
60
|
+
task_routing: {
|
|
61
|
+
review: { models: ['codex', 'claude'], strategy: 'parallel' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result).toEqual({
|
|
67
|
+
models: ['codex', 'claude'],
|
|
68
|
+
strategy: 'parallel',
|
|
69
|
+
source: 'personal-config',
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('reads project overrides from .tlc.json', () => {
|
|
74
|
+
const result = runGeneratedScript({
|
|
75
|
+
commandName: 'build',
|
|
76
|
+
homeConfig: {
|
|
77
|
+
task_routing: {
|
|
78
|
+
build: { models: ['codex'], strategy: 'parallel' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
projectConfig: {
|
|
82
|
+
task_routing_override: {
|
|
83
|
+
build: { models: ['gemini'], strategy: 'single' },
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result).toEqual({
|
|
89
|
+
models: ['gemini'],
|
|
90
|
+
strategy: 'single',
|
|
91
|
+
source: 'project-override',
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('accepts singular model in personal config and normalizes it to models', () => {
|
|
96
|
+
const result = runGeneratedScript({
|
|
97
|
+
commandName: 'build',
|
|
98
|
+
homeConfig: {
|
|
99
|
+
task_routing: {
|
|
100
|
+
build: { model: 'codex', strategy: 'single' },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result).toEqual({
|
|
106
|
+
models: ['codex'],
|
|
107
|
+
strategy: 'single',
|
|
108
|
+
source: 'personal-config',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('prefers models over model in the same entry', () => {
|
|
113
|
+
const result = runGeneratedScript({
|
|
114
|
+
commandName: 'build',
|
|
115
|
+
homeConfig: {
|
|
116
|
+
task_routing: {
|
|
117
|
+
build: { model: 'gemini', models: ['codex', 'claude'], strategy: 'parallel' },
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result).toEqual({
|
|
123
|
+
models: ['codex', 'claude'],
|
|
124
|
+
strategy: 'parallel',
|
|
125
|
+
source: 'personal-config',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('includes model_providers from personal config', () => {
|
|
130
|
+
const providers = {
|
|
131
|
+
claude: { type: 'cli', command: 'claude' },
|
|
132
|
+
codex: { type: 'cli', command: 'codex' },
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const result = runGeneratedScript({
|
|
136
|
+
commandName: 'build',
|
|
137
|
+
homeConfig: {
|
|
138
|
+
task_routing: {
|
|
139
|
+
build: { models: ['codex'], strategy: 'single' },
|
|
140
|
+
},
|
|
141
|
+
model_providers: providers,
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result).toEqual({
|
|
146
|
+
models: ['codex'],
|
|
147
|
+
strategy: 'single',
|
|
148
|
+
source: 'personal-config',
|
|
149
|
+
providers,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('lets a flag override win over project and personal config', () => {
|
|
154
|
+
const result = runGeneratedScript({
|
|
155
|
+
commandName: 'build',
|
|
156
|
+
flagModel: 'local-model',
|
|
157
|
+
homeConfig: {
|
|
158
|
+
task_routing: {
|
|
159
|
+
build: { models: ['codex'], strategy: 'parallel' },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
projectConfig: {
|
|
163
|
+
task_routing_override: {
|
|
164
|
+
build: { models: ['gemini'], strategy: 'parallel' },
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(result).toEqual({
|
|
170
|
+
models: ['local-model'],
|
|
171
|
+
strategy: 'single',
|
|
172
|
+
source: 'flag-override',
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('handles malformed personal config gracefully', () => {
|
|
177
|
+
const homeDir = makeTempDir('routing-home-');
|
|
178
|
+
const cwd = makeTempDir('routing-project-');
|
|
179
|
+
fs.mkdirSync(path.join(homeDir, '.tlc'), { recursive: true });
|
|
180
|
+
fs.writeFileSync(path.join(homeDir, '.tlc', 'config.json'), '{{not-json');
|
|
181
|
+
|
|
182
|
+
const output = execFileSync('bash', ['-lc', generatePreamble('build')], {
|
|
183
|
+
cwd,
|
|
184
|
+
env: {
|
|
185
|
+
...process.env,
|
|
186
|
+
HOME: homeDir,
|
|
187
|
+
},
|
|
188
|
+
encoding: 'utf8',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(JSON.parse(output)).toEqual({
|
|
192
|
+
models: ['claude'],
|
|
193
|
+
strategy: 'single',
|
|
194
|
+
source: 'shipped-defaults',
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('handles malformed project config gracefully', () => {
|
|
199
|
+
const homeDir = makeTempDir('routing-home-');
|
|
200
|
+
const cwd = makeTempDir('routing-project-');
|
|
201
|
+
fs.writeFileSync(path.join(cwd, '.tlc.json'), '{broken');
|
|
202
|
+
|
|
203
|
+
const output = execFileSync('bash', ['-lc', generatePreamble('build')], {
|
|
204
|
+
cwd,
|
|
205
|
+
env: {
|
|
206
|
+
...process.env,
|
|
207
|
+
HOME: homeDir,
|
|
208
|
+
},
|
|
209
|
+
encoding: 'utf8',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(JSON.parse(output)).toEqual({
|
|
213
|
+
models: ['claude'],
|
|
214
|
+
strategy: 'single',
|
|
215
|
+
source: 'shipped-defaults',
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('works in any directory and only consults the runtime cwd', () => {
|
|
220
|
+
const repoLikeDir = makeTempDir('routing-outside-');
|
|
221
|
+
const unrelatedDir = path.join(repoLikeDir, 'nested', 'workspace');
|
|
222
|
+
fs.mkdirSync(unrelatedDir, { recursive: true });
|
|
223
|
+
writeJson(path.join(unrelatedDir, '.tlc.json'), {
|
|
224
|
+
task_routing_override: {
|
|
225
|
+
docs: { model: 'codex', strategy: 'single' },
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const homeDir = makeTempDir('routing-home-');
|
|
230
|
+
const output = execFileSync('bash', ['-lc', generatePreamble('docs')], {
|
|
231
|
+
cwd: unrelatedDir,
|
|
232
|
+
env: {
|
|
233
|
+
...process.env,
|
|
234
|
+
HOME: homeDir,
|
|
235
|
+
},
|
|
236
|
+
encoding: 'utf8',
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(JSON.parse(output)).toEqual({
|
|
240
|
+
models: ['codex'],
|
|
241
|
+
strategy: 'single',
|
|
242
|
+
source: 'project-override',
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('uses the generic default for unknown commands', () => {
|
|
247
|
+
const result = runGeneratedScript({ commandName: 'custom-command' });
|
|
248
|
+
|
|
249
|
+
expect(result).toEqual({
|
|
250
|
+
models: ['claude'],
|
|
251
|
+
strategy: 'single',
|
|
252
|
+
source: 'shipped-defaults',
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -96,6 +96,8 @@ function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDi
|
|
|
96
96
|
if (personalRouting) {
|
|
97
97
|
if (personalRouting.models) {
|
|
98
98
|
models = [...personalRouting.models];
|
|
99
|
+
} else if (personalRouting.model) {
|
|
100
|
+
models = [personalRouting.model];
|
|
99
101
|
}
|
|
100
102
|
if (personalRouting.strategy) {
|
|
101
103
|
strategy = personalRouting.strategy;
|
|
@@ -111,6 +113,8 @@ function resolveRouting({ command, flagModel, projectDir = process.cwd(), homeDi
|
|
|
111
113
|
if (overrideEntry) {
|
|
112
114
|
if (overrideEntry.models) {
|
|
113
115
|
models = [...overrideEntry.models];
|
|
116
|
+
} else if (overrideEntry.model) {
|
|
117
|
+
models = [overrideEntry.model];
|
|
114
118
|
}
|
|
115
119
|
if (overrideEntry.strategy) {
|
|
116
120
|
strategy = overrideEntry.strategy;
|
|
@@ -412,6 +412,71 @@ describe('task-router-config', () => {
|
|
|
412
412
|
expect(result.providers).toEqual(personalConfig.model_providers);
|
|
413
413
|
});
|
|
414
414
|
|
|
415
|
+
it('personal config with singular model (string) is normalized to models array', () => {
|
|
416
|
+
const personalConfig = {
|
|
417
|
+
task_routing: {
|
|
418
|
+
build: { model: 'codex', strategy: 'single' },
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
const fs = mockFs({
|
|
422
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const result = resolveRouting({
|
|
426
|
+
command: 'build',
|
|
427
|
+
projectDir: '/project',
|
|
428
|
+
homeDir: '/home/user',
|
|
429
|
+
fs,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
expect(result.models).toEqual(['codex']);
|
|
433
|
+
expect(result.strategy).toBe('single');
|
|
434
|
+
expect(result.source).toBe('personal-config');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('project override with singular model (string) is normalized to models array', () => {
|
|
438
|
+
const tlcJson = {
|
|
439
|
+
task_routing_override: {
|
|
440
|
+
review: { model: 'gemini', strategy: 'single' },
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
const fs = mockFs({
|
|
444
|
+
'/project/.tlc.json': JSON.stringify(tlcJson),
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const result = resolveRouting({
|
|
448
|
+
command: 'review',
|
|
449
|
+
projectDir: '/project',
|
|
450
|
+
homeDir: '/home/user',
|
|
451
|
+
fs,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
expect(result.models).toEqual(['gemini']);
|
|
455
|
+
expect(result.strategy).toBe('single');
|
|
456
|
+
expect(result.source).toBe('project-override');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('models array takes precedence over model string in same config entry', () => {
|
|
460
|
+
const personalConfig = {
|
|
461
|
+
task_routing: {
|
|
462
|
+
build: { model: 'gemini', models: ['codex', 'claude'], strategy: 'parallel' },
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
const fs = mockFs({
|
|
466
|
+
'/home/user/.tlc/config.json': JSON.stringify(personalConfig),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const result = resolveRouting({
|
|
470
|
+
command: 'build',
|
|
471
|
+
projectDir: '/project',
|
|
472
|
+
homeDir: '/home/user',
|
|
473
|
+
fs,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(result.models).toEqual(['codex', 'claude']);
|
|
477
|
+
expect(result.strategy).toBe('parallel');
|
|
478
|
+
});
|
|
479
|
+
|
|
415
480
|
it('returns no providers when personal config has none', () => {
|
|
416
481
|
const fs = mockFs({});
|
|
417
482
|
|