tlc-claude-code 2.4.2 → 2.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.claude/commands/tlc/build.md +75 -5
  2. package/.claude/commands/tlc/discuss.md +174 -123
  3. package/.claude/commands/tlc/e2e-verify.md +1 -1
  4. package/.claude/commands/tlc/plan.md +77 -2
  5. package/.claude/commands/tlc/recall.md +59 -87
  6. package/.claude/commands/tlc/remember.md +76 -71
  7. package/.claude/commands/tlc/review.md +76 -21
  8. package/.claude/commands/tlc/tlc.md +204 -473
  9. package/.claude/hooks/tlc-capture-exchange.sh +50 -21
  10. package/.claude/hooks/tlc-session-init.sh +30 -0
  11. package/CLAUDE.md +6 -5
  12. package/bin/init.js +12 -3
  13. package/package.json +4 -1
  14. package/scripts/dev-link.sh +29 -0
  15. package/scripts/test-package.sh +54 -0
  16. package/scripts/version-sync.js +42 -0
  17. package/scripts/version-sync.test.js +100 -0
  18. package/server/lib/capture/classifier.js +71 -0
  19. package/server/lib/capture/classifier.test.js +71 -0
  20. package/server/lib/capture/claude-capture.js +140 -0
  21. package/server/lib/capture/claude-capture.test.js +152 -0
  22. package/server/lib/capture/codex-capture.js +79 -0
  23. package/server/lib/capture/codex-capture.test.js +161 -0
  24. package/server/lib/capture/codex-event-parser.js +76 -0
  25. package/server/lib/capture/codex-event-parser.test.js +83 -0
  26. package/server/lib/capture/ensure-ready.js +56 -0
  27. package/server/lib/capture/ensure-ready.test.js +135 -0
  28. package/server/lib/capture/envelope.js +77 -0
  29. package/server/lib/capture/envelope.test.js +169 -0
  30. package/server/lib/capture/extractor.js +51 -0
  31. package/server/lib/capture/extractor.test.js +92 -0
  32. package/server/lib/capture/generic-capture.js +96 -0
  33. package/server/lib/capture/generic-capture.test.js +171 -0
  34. package/server/lib/capture/index.js +117 -0
  35. package/server/lib/capture/index.test.js +263 -0
  36. package/server/lib/capture/redactor.js +68 -0
  37. package/server/lib/capture/redactor.test.js +93 -0
  38. package/server/lib/capture/spool-processor.js +155 -0
  39. package/server/lib/capture/spool-processor.test.js +278 -0
  40. package/server/lib/health-check.js +255 -0
  41. package/server/lib/health-check.test.js +243 -0
  42. package/server/lib/model-router.js +11 -2
  43. package/server/lib/model-router.test.js +27 -1
  44. package/server/lib/orchestration/cli-dispatch.js +200 -0
  45. package/server/lib/orchestration/cli-dispatch.test.js +242 -0
  46. package/server/lib/orchestration/codex-orchestrator.js +185 -0
  47. package/server/lib/orchestration/codex-orchestrator.test.js +221 -0
  48. package/server/lib/orchestration/dep-linker.js +61 -0
  49. package/server/lib/orchestration/dep-linker.test.js +174 -0
  50. package/server/lib/orchestration/prompt-builder.js +118 -0
  51. package/server/lib/orchestration/prompt-builder.test.js +200 -0
  52. package/server/lib/orchestration/standalone-compat.js +39 -0
  53. package/server/lib/orchestration/standalone-compat.test.js +144 -0
  54. package/server/lib/orchestration/worktree-manager.js +43 -0
  55. package/server/lib/orchestration/worktree-manager.test.js +50 -0
  56. package/server/lib/router-config.js +18 -3
  57. package/server/lib/router-config.test.js +57 -1
  58. package/server/lib/routing/index.js +34 -0
  59. package/server/lib/routing/index.test.js +33 -0
  60. package/server/lib/routing-command.js +11 -2
  61. package/server/lib/routing-command.test.js +39 -1
  62. package/server/lib/routing-preamble.integration.test.js +319 -0
  63. package/server/lib/routing-preamble.js +34 -11
  64. package/server/lib/routing-preamble.test.js +11 -0
  65. package/server/lib/task-router-config.js +35 -14
  66. package/server/lib/task-router-config.test.js +77 -13
@@ -0,0 +1,200 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { createRequire } from 'module';
5
+ import { describe, it, expect, afterEach } from 'vitest';
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ const { packagePrompt } = require('../prompt-packager.js');
10
+ const { buildTaskPrompt, buildContextPrompt, buildFullPrompt } = require('./prompt-builder.js');
11
+
12
+ const tempDirs = [];
13
+
14
+ function makeTempProject() {
15
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-prompt-builder-'));
16
+ tempDirs.push(dir);
17
+ return dir;
18
+ }
19
+
20
+ afterEach(() => {
21
+ while (tempDirs.length > 0) {
22
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ describe('prompt-builder', () => {
27
+ describe('buildTaskPrompt', () => {
28
+ it('matches the existing dispatcher prompt format and includes TDD instructions', () => {
29
+ const result = buildTaskPrompt({
30
+ goal: 'Implement login',
31
+ files: ['src/login.js', 'src/login.test.js'],
32
+ criteria: ['Supports valid credentials', 'Rejects bad passwords'],
33
+ testCases: ['logs in a valid user', 'rejects an invalid user'],
34
+ });
35
+
36
+ expect(result).toBe([
37
+ 'Goal: Implement login',
38
+ '',
39
+ 'Files to work with:',
40
+ ' - src/login.js',
41
+ ' - src/login.test.js',
42
+ '',
43
+ 'Acceptance criteria:',
44
+ ' - Supports valid credentials',
45
+ ' - Rejects bad passwords',
46
+ '',
47
+ 'Test cases:',
48
+ ' - logs in a valid user',
49
+ ' - rejects an invalid user',
50
+ '',
51
+ 'Methodology: Write tests first (red → green → refactor). You MUST write the test before implementing any code. Test-first is required.',
52
+ ].join('\n'));
53
+ });
54
+
55
+ it('supports empty file, criteria, and test case lists', () => {
56
+ const result = buildTaskPrompt({ goal: 'Implement logout' });
57
+
58
+ expect(result).toContain('Goal: Implement logout');
59
+ expect(result).toContain('Files to work with:\n');
60
+ expect(result).toContain('Acceptance criteria:\n');
61
+ expect(result).toContain('Test cases:\n');
62
+ expect(result).toContain('Write tests first');
63
+ });
64
+ });
65
+
66
+ describe('buildContextPrompt', () => {
67
+ it('delegates to packagePrompt with the provided options', () => {
68
+ const input = {
69
+ agentPrompt: 'Do the task',
70
+ projectDoc: '# Project',
71
+ planDoc: '# Plan',
72
+ codingStandards: '# Standards',
73
+ files: [
74
+ { path: 'b.js', content: 'b' },
75
+ { path: 'a.js', content: 'a' },
76
+ ],
77
+ tokenBudget: 500,
78
+ };
79
+
80
+ expect(buildContextPrompt(input)).toBe(packagePrompt(input));
81
+ });
82
+ });
83
+
84
+ describe('buildFullPrompt', () => {
85
+ it('reads project docs and relevant files, then packages the full prompt', () => {
86
+ const projectDir = makeTempProject();
87
+ fs.mkdirSync(path.join(projectDir, '.planning', 'phases'), { recursive: true });
88
+ fs.writeFileSync(path.join(projectDir, 'PROJECT.md'), '# Project\nShip it');
89
+ fs.writeFileSync(path.join(projectDir, 'CODING-STANDARDS.md'), '# Standards\nWrite tests');
90
+ fs.writeFileSync(path.join(projectDir, '.planning', 'phases', '1-PLAN.md'), '# Phase 1\nDo it');
91
+ fs.mkdirSync(path.join(projectDir, 'src'), { recursive: true });
92
+ fs.writeFileSync(path.join(projectDir, 'src', 'feature.js'), 'module.exports = { feature: true };');
93
+ fs.writeFileSync(path.join(projectDir, 'src', 'feature.test.js'), 'test("feature", () => {});');
94
+
95
+ const result = buildFullPrompt({
96
+ task: {
97
+ goal: 'Build feature',
98
+ files: ['src/feature.js', 'src/feature.test.js'],
99
+ criteria: ['Feature works'],
100
+ testCases: ['Feature passes'],
101
+ },
102
+ projectDir,
103
+ phase: 1,
104
+ });
105
+
106
+ expect(result).toContain('--- Task ---');
107
+ expect(result).toContain('Goal: Build feature');
108
+ expect(result).toContain('--- PROJECT.md ---');
109
+ expect(result).toContain('# Project\nShip it');
110
+ expect(result).toContain('--- Current Phase Plan ---');
111
+ expect(result).toContain('# Phase 1\nDo it');
112
+ expect(result).toContain('--- Coding Standards ---');
113
+ expect(result).toContain('# Standards\nWrite tests');
114
+ expect(result).toContain('--- src/feature.js ---');
115
+ expect(result).toContain('module.exports = { feature: true };');
116
+ expect(result).toContain('--- src/feature.test.js ---');
117
+ });
118
+
119
+ it('resolves padded or prefixed phase plan filenames', () => {
120
+ const projectDir = makeTempProject();
121
+ fs.mkdirSync(path.join(projectDir, '.planning', 'phases'), { recursive: true });
122
+ fs.writeFileSync(path.join(projectDir, 'PROJECT.md'), '# Project');
123
+ fs.writeFileSync(path.join(projectDir, 'CODING-STANDARDS.md'), '# Standards');
124
+ fs.writeFileSync(path.join(projectDir, '.planning', 'phases', '02-api-PLAN.md'), '# Phase 2');
125
+
126
+ const result = buildFullPrompt({
127
+ task: { goal: 'Build API' },
128
+ projectDir,
129
+ phase: 2,
130
+ });
131
+
132
+ expect(result).toContain('--- Current Phase Plan ---');
133
+ expect(result).toContain('# Phase 2');
134
+ });
135
+
136
+ it('skips missing docs and missing task files instead of throwing', () => {
137
+ const projectDir = makeTempProject();
138
+
139
+ const result = buildFullPrompt({
140
+ task: {
141
+ goal: 'Build minimal task',
142
+ files: ['missing.js'],
143
+ },
144
+ projectDir,
145
+ phase: 3,
146
+ });
147
+
148
+ expect(result).toContain('--- Task ---');
149
+ expect(result).toContain('Goal: Build minimal task');
150
+ expect(result).not.toContain('--- PROJECT.md ---');
151
+ expect(result).not.toContain('--- Current Phase Plan ---');
152
+ expect(result).not.toContain('--- Coding Standards ---');
153
+ expect(result).not.toContain('--- Relevant Files ---');
154
+ });
155
+
156
+ it('supports injected fs implementations', () => {
157
+ const fakeFs = {
158
+ existsSync(targetPath) {
159
+ return [
160
+ '/project/PROJECT.md',
161
+ '/project/CODING-STANDARDS.md',
162
+ '/project/.planning/phases',
163
+ '/project/.planning/phases/4-build-PLAN.md',
164
+ '/project/src/app.js',
165
+ ].includes(targetPath);
166
+ },
167
+ readdirSync(targetPath) {
168
+ if (targetPath === '/project/.planning/phases') {
169
+ return ['4-build-PLAN.md'];
170
+ }
171
+ return [];
172
+ },
173
+ readFileSync(targetPath) {
174
+ const contents = {
175
+ '/project/PROJECT.md': '# Project',
176
+ '/project/CODING-STANDARDS.md': '# Standards',
177
+ '/project/.planning/phases/4-build-PLAN.md': '# Phase 4',
178
+ '/project/src/app.js': 'console.log("app");',
179
+ };
180
+ return contents[targetPath];
181
+ },
182
+ };
183
+
184
+ const result = buildFullPrompt({
185
+ task: {
186
+ goal: 'Build app',
187
+ files: ['src/app.js'],
188
+ },
189
+ projectDir: '/project',
190
+ phase: 4,
191
+ fs: fakeFs,
192
+ });
193
+
194
+ expect(result).toContain('# Project');
195
+ expect(result).toContain('# Standards');
196
+ expect(result).toContain('# Phase 4');
197
+ expect(result).toContain('--- src/app.js ---');
198
+ });
199
+ });
200
+ });
@@ -0,0 +1,39 @@
1
+ const path = require('path');
2
+
3
+ function guessProvider(binary) {
4
+ const basename = path.basename(String(binary || '')).toLowerCase();
5
+
6
+ if (['codex', 'claude', 'gemini', 'ollama'].includes(basename)) {
7
+ return basename;
8
+ }
9
+
10
+ return basename;
11
+ }
12
+
13
+ async function executeCli(prompt, endpoint, options = {}) {
14
+ const { dispatch } = require('./cli-dispatch');
15
+ const provider = guessProvider(endpoint && endpoint.binary);
16
+ const result = await dispatch({
17
+ ...options,
18
+ prompt,
19
+ provider,
20
+ // Preserve the original binary path so custom/absolute paths still work
21
+ binaryOverride: endpoint && endpoint.binary,
22
+ flags: Array.isArray(endpoint && endpoint.cli_flags) ? endpoint.cli_flags : [],
23
+ });
24
+
25
+ return {
26
+ response: result.stdout,
27
+ provider: endpoint && endpoint.name,
28
+ latency_ms: result.duration,
29
+ };
30
+ }
31
+
32
+ function buildTaskPrompt(task) {
33
+ return require('./prompt-builder').buildTaskPrompt(task);
34
+ }
35
+
36
+ module.exports = {
37
+ executeCli,
38
+ buildTaskPrompt,
39
+ };
@@ -0,0 +1,144 @@
1
+ import path from 'path';
2
+ import { describe, it, expect, vi, afterEach } from 'vitest';
3
+
4
+ const cliDispatch = require('./cli-dispatch.js');
5
+ const promptBuilder = require('./prompt-builder.js');
6
+ const { executeCli, buildTaskPrompt } = require('./standalone-compat.js');
7
+
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ describe('standalone-compat', () => {
13
+ describe('executeCli', () => {
14
+ it('delegates to cli-dispatch with provider guessed from codex binary', async () => {
15
+ const dispatchSpy = vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
16
+ stdout: 'ok',
17
+ duration: 42,
18
+ });
19
+
20
+ await executeCli(
21
+ 'Ship it',
22
+ {
23
+ binary: 'codex',
24
+ cli_flags: ['--model', 'gpt-5.4'],
25
+ name: 'Codex CLI',
26
+ },
27
+ {
28
+ timeout: 5000,
29
+ worktreePath: '/tmp/worktree',
30
+ }
31
+ );
32
+
33
+ expect(dispatchSpy).toHaveBeenCalledWith({
34
+ prompt: 'Ship it',
35
+ provider: 'codex',
36
+ binaryOverride: 'codex',
37
+ flags: ['--model', 'gpt-5.4'],
38
+ timeout: 5000,
39
+ worktreePath: '/tmp/worktree',
40
+ });
41
+ });
42
+
43
+ it('guesses provider from the binary basename for known CLIs', async () => {
44
+ const dispatchSpy = vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
45
+ stdout: 'done',
46
+ duration: 12,
47
+ });
48
+
49
+ await executeCli('Review this', {
50
+ binary: path.join('/usr', 'local', 'bin', 'claude'),
51
+ cli_flags: ['--permission-mode', 'auto'],
52
+ name: 'Claude Desktop',
53
+ });
54
+
55
+ expect(dispatchSpy).toHaveBeenCalledWith({
56
+ prompt: 'Review this',
57
+ provider: 'claude',
58
+ binaryOverride: path.join('/usr', 'local', 'bin', 'claude'),
59
+ flags: ['--permission-mode', 'auto'],
60
+ });
61
+ });
62
+
63
+ it('passes through empty flags when cli_flags is not provided', async () => {
64
+ const dispatchSpy = vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
65
+ stdout: 'done',
66
+ duration: 7,
67
+ });
68
+
69
+ await executeCli('Run', {
70
+ binary: 'gemini',
71
+ name: 'Gemini CLI',
72
+ });
73
+
74
+ expect(dispatchSpy).toHaveBeenCalledWith({
75
+ prompt: 'Run',
76
+ provider: 'gemini',
77
+ binaryOverride: 'gemini',
78
+ flags: [],
79
+ });
80
+ });
81
+
82
+ it('falls back to the binary basename for unknown providers and reshapes the result', async () => {
83
+ vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
84
+ stdout: 'custom output',
85
+ duration: 88,
86
+ });
87
+
88
+ const result = await executeCli('Custom task', {
89
+ binary: '/opt/tools/custom-runner',
90
+ cli_flags: ['--fast'],
91
+ name: 'Standalone Custom',
92
+ });
93
+
94
+ expect(result).toEqual({
95
+ response: 'custom output',
96
+ provider: 'Standalone Custom',
97
+ latency_ms: 88,
98
+ });
99
+ });
100
+
101
+ it('lets explicit options like spawn pass through unchanged', async () => {
102
+ const spawn = vi.fn();
103
+ const dispatchSpy = vi.spyOn(cliDispatch, 'dispatch').mockResolvedValue({
104
+ stdout: 'ok',
105
+ duration: 3,
106
+ });
107
+
108
+ await executeCli(
109
+ 'Test',
110
+ {
111
+ binary: 'ollama',
112
+ cli_flags: ['llama3.2'],
113
+ name: 'Ollama',
114
+ },
115
+ { spawn }
116
+ );
117
+
118
+ expect(dispatchSpy).toHaveBeenCalledWith({
119
+ prompt: 'Test',
120
+ provider: 'ollama',
121
+ binaryOverride: 'ollama',
122
+ flags: ['llama3.2'],
123
+ spawn,
124
+ });
125
+ });
126
+ });
127
+
128
+ describe('buildTaskPrompt', () => {
129
+ it('delegates directly to prompt-builder and returns its string', () => {
130
+ const task = {
131
+ goal: 'Implement feature',
132
+ files: ['src/feature.js'],
133
+ criteria: ['It works'],
134
+ testCases: ['passes tests'],
135
+ };
136
+ const buildSpy = vi.spyOn(promptBuilder, 'buildTaskPrompt').mockReturnValue('prompt text');
137
+
138
+ const result = buildTaskPrompt(task);
139
+
140
+ expect(buildSpy).toHaveBeenCalledWith(task);
141
+ expect(result).toBe('prompt text');
142
+ });
143
+ });
144
+ });
@@ -16,6 +16,26 @@ export function sanitizeName(name) {
16
16
  .replace(/-{2,}/g, '-');
17
17
  }
18
18
 
19
+ /**
20
+ * Build a short, stable task slug from a task name.
21
+ * Uses the first 4 words, lowercases, hyphenates, and limits to 30 chars.
22
+ * @param {string} taskName
23
+ * @returns {string}
24
+ */
25
+ export function sanitizeTaskName(taskName) {
26
+ const words = String(taskName)
27
+ .trim()
28
+ .toLowerCase()
29
+ .split(/\s+/)
30
+ .filter(Boolean)
31
+ .slice(0, 4);
32
+
33
+ const truncated = words.join('-').slice(0, 30);
34
+ return sanitizeName(truncated)
35
+ .replace(/^-+|-+$/g, '')
36
+ .replace(/-{2,}/g, '-');
37
+ }
38
+
19
39
  /**
20
40
  * Create a git worktree for the given name.
21
41
  * @param {string} name - Worktree name (will be sanitized)
@@ -47,6 +67,29 @@ export function createWorktree(name, { exec, maxConcurrent } = {}) {
47
67
  return { name: safe, branch, path };
48
68
  }
49
69
 
70
+ /**
71
+ * Create a git worktree named for a phase/task combination.
72
+ * @param {number|string} phase
73
+ * @param {number|string} taskNumber
74
+ * @param {string} taskName
75
+ * @param {{ exec: Function, baseBranch?: string }} options
76
+ * @returns {{ name: string, branch: string, path: string }}
77
+ */
78
+ export function createTaskWorktree(phase, taskNumber, taskName, { exec, baseBranch } = {}) {
79
+ const taskSlug = sanitizeTaskName(taskName);
80
+ const rawName = taskSlug
81
+ ? `phase-${phase}-task-${taskNumber}-${taskSlug}`
82
+ : `phase-${phase}-task-${taskNumber}`;
83
+ const name = sanitizeName(rawName);
84
+ const branch = `worktree-${name}`;
85
+ const path = `.claude/worktrees/${name}`;
86
+ const startPoint = baseBranch || exec('git branch --show-current').trim();
87
+
88
+ exec(`git worktree add -b ${branch} ${path} ${startPoint}`);
89
+
90
+ return { name, branch, path };
91
+ }
92
+
50
93
  /**
51
94
  * List active worktrees under .claude/worktrees/.
52
95
  * @param {{ exec: Function }} options
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import {
3
3
  createWorktree,
4
+ createTaskWorktree,
4
5
  listWorktrees,
5
6
  mergeWorktree,
6
7
  removeWorktree,
@@ -58,6 +59,55 @@ describe('worktree-manager', () => {
58
59
  });
59
60
  });
60
61
 
62
+ describe('createTaskWorktree', () => {
63
+ it('builds a phase/task-based worktree name', () => {
64
+ exec.mockImplementation((cmd) => {
65
+ if (cmd === 'git branch --show-current') return 'feature/current\n';
66
+ return '';
67
+ });
68
+
69
+ const result = createTaskWorktree(30, 5, 'Config Size', { exec });
70
+
71
+ expect(result).toEqual({
72
+ name: 'phase-30-task-5-config-size',
73
+ branch: 'worktree-phase-30-task-5-config-size',
74
+ path: '.claude/worktrees/phase-30-task-5-config-size',
75
+ });
76
+ expect(exec).toHaveBeenCalledWith(
77
+ 'git worktree add -b worktree-phase-30-task-5-config-size .claude/worktrees/phase-30-task-5-config-size feature/current'
78
+ );
79
+ });
80
+
81
+ it('sanitizes task name to first four words and max 30 chars', () => {
82
+ exec.mockImplementation((cmd) => {
83
+ if (cmd === 'git branch --show-current') return 'main\n';
84
+ return '';
85
+ });
86
+
87
+ const result = createTaskWorktree(
88
+ 12,
89
+ 9,
90
+ 'Configuration synchronization validation instrumentation extra trailing words',
91
+ { exec }
92
+ );
93
+
94
+ expect(result.name).toBe('phase-12-task-9-configuration-synchronization');
95
+ expect(result.branch).toBe('worktree-phase-12-task-9-configuration-synchronization');
96
+ });
97
+
98
+ it('uses a provided base branch instead of the current branch', () => {
99
+ exec.mockReturnValue('');
100
+
101
+ const result = createTaskWorktree(7, 2, 'Ship Metrics', { exec, baseBranch: 'release/7' });
102
+
103
+ expect(result.name).toBe('phase-7-task-2-ship-metrics');
104
+ expect(exec).toHaveBeenCalledTimes(1);
105
+ expect(exec).toHaveBeenCalledWith(
106
+ 'git worktree add -b worktree-phase-7-task-2-ship-metrics .claude/worktrees/phase-7-task-2-ship-metrics release/7'
107
+ );
108
+ });
109
+ });
110
+
61
111
  describe('listWorktrees', () => {
62
112
  it('returns active worktrees with branch and path', () => {
63
113
  exec.mockReturnValue(
@@ -20,6 +20,10 @@ export const defaultConfig = {
20
20
  devserver: { url: null, queue: { maxConcurrent: 3, timeout: 120000 } },
21
21
  };
22
22
 
23
+ function isMissingConfigError(error) {
24
+ return error?.code === 'ENOENT' || error?.message?.includes('ENOENT');
25
+ }
26
+
23
27
  export async function loadRouterConfig(options = {}) {
24
28
  try {
25
29
  let content;
@@ -38,7 +42,13 @@ export async function loadRouterConfig(options = {}) {
38
42
  capabilities: { ...defaultConfig.capabilities, ...routerConfig.capabilities },
39
43
  devserver: { ...defaultConfig.devserver, ...routerConfig.devserver },
40
44
  };
41
- } catch {
45
+ } catch (error) {
46
+ if (!isMissingConfigError(error)) {
47
+ const message = error instanceof SyntaxError
48
+ ? 'Failed to parse .tlc.json router config; using defaults'
49
+ : `Failed to load .tlc.json router config; using defaults (${error?.message || 'unknown error'})`;
50
+ console.warn(`[TLC WARNING] ${message}`);
51
+ }
42
52
  return { ...defaultConfig };
43
53
  }
44
54
  }
@@ -75,8 +85,13 @@ export async function saveRouterConfig(config, options = {}) {
75
85
  try {
76
86
  const content = await readFile(configPath, 'utf-8');
77
87
  existing = JSON.parse(content);
78
- } catch {
79
- // New file
88
+ } catch (error) {
89
+ if (!isMissingConfigError(error)) {
90
+ const message = error instanceof SyntaxError
91
+ ? 'Failed to parse existing .tlc.json router config; overwriting router section'
92
+ : `Failed to load existing .tlc.json router config; overwriting router section (${error?.message || 'unknown error'})`;
93
+ console.warn(`[TLC WARNING] ${message}`);
94
+ }
80
95
  }
81
96
 
82
97
  existing.router = config;
@@ -1,4 +1,7 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+ import { mkdtemp, rm, writeFile } from 'fs/promises';
3
+ import { tmpdir } from 'os';
4
+ import { join } from 'path';
2
5
  import {
3
6
  loadRouterConfig,
4
7
  validateConfig,
@@ -9,6 +12,13 @@ import {
9
12
  } from './router-config.js';
10
13
 
11
14
  describe('Router Config', () => {
15
+ const originalCwd = process.cwd();
16
+
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ process.chdir(originalCwd);
20
+ });
21
+
12
22
  describe('loadRouterConfig', () => {
13
23
  it('reads from .tlc.json', async () => {
14
24
  const config = await loadRouterConfig({ _readFile: vi.fn().mockResolvedValue(JSON.stringify({
@@ -28,6 +38,32 @@ describe('Router Config', () => {
28
38
  const config = await loadRouterConfig({ _readFile: vi.fn().mockResolvedValue('{}') });
29
39
  expect(config.providers).toBeDefined();
30
40
  });
41
+
42
+ it('warns when router config JSON is invalid', async () => {
43
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
44
+
45
+ const config = await loadRouterConfig({
46
+ _readFile: vi.fn().mockResolvedValue('{invalid json'),
47
+ });
48
+
49
+ expect(config).toEqual({ ...defaultConfig });
50
+ expect(warnSpy).toHaveBeenCalledWith(
51
+ '[TLC WARNING] Failed to parse .tlc.json router config; using defaults'
52
+ );
53
+ });
54
+
55
+ it('stays silent when router config file is missing', async () => {
56
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
57
+ const error = new Error('ENOENT');
58
+ error.code = 'ENOENT';
59
+
60
+ const config = await loadRouterConfig({
61
+ _readFile: vi.fn().mockRejectedValue(error),
62
+ });
63
+
64
+ expect(config).toEqual({ ...defaultConfig });
65
+ expect(warnSpy).not.toHaveBeenCalled();
66
+ });
31
67
  });
32
68
 
33
69
  describe('validateConfig', () => {
@@ -79,5 +115,25 @@ describe('Router Config', () => {
79
115
  await saveRouterConfig({ providers: {} }, { _writeFile: writeFile });
80
116
  expect(writeFile).toHaveBeenCalled();
81
117
  });
118
+
119
+ it('warns when existing router config JSON is invalid', async () => {
120
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
121
+ const tempDir = await mkdtemp(join(tmpdir(), 'router-config-test-'));
122
+ await writeFile(join(tempDir, '.tlc.json'), '{invalid json');
123
+ process.chdir(tempDir);
124
+
125
+ await saveRouterConfig(
126
+ { providers: {} },
127
+ {
128
+ _writeFile: vi.fn().mockResolvedValue(undefined),
129
+ },
130
+ );
131
+
132
+ expect(warnSpy).toHaveBeenCalledWith(
133
+ '[TLC WARNING] Failed to parse existing .tlc.json router config; overwriting router section'
134
+ );
135
+
136
+ await rm(tempDir, { recursive: true, force: true });
137
+ });
82
138
  });
83
139
  });
@@ -0,0 +1,34 @@
1
+ const {
2
+ resolveRouting,
3
+ loadPersonalConfig,
4
+ loadProjectOverride,
5
+ SHIPPED_DEFAULTS,
6
+ ROUTABLE_COMMANDS,
7
+ } = require('../task-router-config.js');
8
+ const { dispatch, buildProviderCommand } = require('../cli-dispatcher.js');
9
+ const { routeCommand } = require('../command-router.js');
10
+ const {
11
+ showRouting,
12
+ showProviders,
13
+ savePersonalRouting,
14
+ formatRoutingTable,
15
+ formatProviderList,
16
+ } = require('../routing-command.js');
17
+ const { generatePreamble } = require('../routing-preamble.js');
18
+
19
+ module.exports = {
20
+ resolveRouting,
21
+ loadPersonalConfig,
22
+ loadProjectOverride,
23
+ SHIPPED_DEFAULTS,
24
+ ROUTABLE_COMMANDS,
25
+ dispatch,
26
+ buildProviderCommand,
27
+ routeCommand,
28
+ showRouting,
29
+ showProviders,
30
+ savePersonalRouting,
31
+ formatRoutingTable,
32
+ formatProviderList,
33
+ generatePreamble,
34
+ };