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.
- package/.claude/commands/tlc/build.md +75 -5
- 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/recall.md +59 -87
- package/.claude/commands/tlc/remember.md +76 -71
- package/.claude/commands/tlc/review.md +76 -21
- package/.claude/commands/tlc/tlc.md +204 -473
- package/.claude/hooks/tlc-capture-exchange.sh +50 -21
- package/.claude/hooks/tlc-session-init.sh +30 -0
- package/CLAUDE.md +6 -5
- package/bin/init.js +12 -3
- 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/capture/classifier.js +71 -0
- package/server/lib/capture/classifier.test.js +71 -0
- package/server/lib/capture/claude-capture.js +140 -0
- package/server/lib/capture/claude-capture.test.js +152 -0
- package/server/lib/capture/codex-capture.js +79 -0
- package/server/lib/capture/codex-capture.test.js +161 -0
- package/server/lib/capture/codex-event-parser.js +76 -0
- package/server/lib/capture/codex-event-parser.test.js +83 -0
- package/server/lib/capture/ensure-ready.js +56 -0
- package/server/lib/capture/ensure-ready.test.js +135 -0
- package/server/lib/capture/envelope.js +77 -0
- package/server/lib/capture/envelope.test.js +169 -0
- package/server/lib/capture/extractor.js +51 -0
- package/server/lib/capture/extractor.test.js +92 -0
- package/server/lib/capture/generic-capture.js +96 -0
- package/server/lib/capture/generic-capture.test.js +171 -0
- package/server/lib/capture/index.js +117 -0
- package/server/lib/capture/index.test.js +263 -0
- package/server/lib/capture/redactor.js +68 -0
- package/server/lib/capture/redactor.test.js +93 -0
- package/server/lib/capture/spool-processor.js +155 -0
- package/server/lib/capture/spool-processor.test.js +278 -0
- package/server/lib/health-check.js +255 -0
- package/server/lib/health-check.test.js +243 -0
- package/server/lib/model-router.js +11 -2
- package/server/lib/model-router.test.js +27 -1
- package/server/lib/orchestration/cli-dispatch.js +200 -0
- package/server/lib/orchestration/cli-dispatch.test.js +242 -0
- 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/orchestration/prompt-builder.js +118 -0
- package/server/lib/orchestration/prompt-builder.test.js +200 -0
- package/server/lib/orchestration/standalone-compat.js +39 -0
- package/server/lib/orchestration/standalone-compat.test.js +144 -0
- package/server/lib/orchestration/worktree-manager.js +43 -0
- package/server/lib/orchestration/worktree-manager.test.js +50 -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
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
const MEMORY_DIRS = [
|
|
5
|
+
'.tlc/memory/team/decisions',
|
|
6
|
+
'.tlc/memory/team/gotchas',
|
|
7
|
+
'.tlc/memory/.local/sessions',
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
function passResult(message, autoFixed = []) {
|
|
11
|
+
return {
|
|
12
|
+
passed: message,
|
|
13
|
+
warning: null,
|
|
14
|
+
autoFixed,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function warningResult(message, autoFixed = []) {
|
|
19
|
+
return {
|
|
20
|
+
passed: null,
|
|
21
|
+
warning: `[TLC WARNING] ${message}`,
|
|
22
|
+
autoFixed,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function ensureMemoryDirs(projectDir, { fs = require('fs') } = {}) {
|
|
27
|
+
const autoFixed = [];
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
for (const relativeDir of MEMORY_DIRS) {
|
|
31
|
+
const fullPath = path.join(projectDir, relativeDir);
|
|
32
|
+
if (!fs.existsSync(fullPath)) {
|
|
33
|
+
fs.mkdirSync(fullPath, { recursive: true });
|
|
34
|
+
autoFixed.push(relativeDir);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return passResult('Memory directories ready', autoFixed);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return warningResult(
|
|
41
|
+
`Unable to verify memory directories under .tlc/memory: ${error.message}`,
|
|
42
|
+
autoFixed
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function checkGitignoreTeamMemory(
|
|
48
|
+
projectDir,
|
|
49
|
+
{ execSync = require('child_process').execSync } = {}
|
|
50
|
+
) {
|
|
51
|
+
try {
|
|
52
|
+
const output = execSync('git check-ignore .tlc/memory/team/test.md 2>/dev/null', {
|
|
53
|
+
cwd: projectDir,
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (String(output || '').trim()) {
|
|
59
|
+
return warningResult(
|
|
60
|
+
'gitignore is blocking team memory. Remove ignore rules covering .tlc/memory/team/.'
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return passResult('Team memory is not ignored by git');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error && error.status === 1) {
|
|
67
|
+
return passResult('Team memory is not ignored by git');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return warningResult(
|
|
71
|
+
`Unable to confirm gitignore rules for .tlc/memory/team/. Run git check-ignore manually. ${error.message}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function checkSpoolEntries(projectDir, { fs = require('fs') } = {}) {
|
|
77
|
+
const spoolPath = path.join(projectDir, '.tlc', 'memory', '.spool.jsonl');
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
if (!fs.existsSync(spoolPath)) {
|
|
81
|
+
return passResult('No unprocessed spool entries');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const size = fs.statSync(spoolPath).size;
|
|
85
|
+
if (size > 0) {
|
|
86
|
+
return warningResult(
|
|
87
|
+
`Found unprocessed spool entries in .tlc/memory/.spool.jsonl (${size} bytes). Drain or inspect the spool.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return passResult('No unprocessed spool entries');
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return warningResult(
|
|
94
|
+
`Unable to inspect .tlc/memory/.spool.jsonl for unprocessed entries: ${error.message}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function checkCaptureHookInstalled(projectDir, { fs = require('fs') } = {}) {
|
|
100
|
+
const hookPath = path.join(projectDir, '.claude', 'hooks', 'tlc-capture-exchange.sh');
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
if (fs.existsSync(hookPath)) {
|
|
104
|
+
return passResult('Capture hook installed');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return warningResult(
|
|
108
|
+
'Missing capture hook at .claude/hooks/tlc-capture-exchange.sh. Install the TLC capture hook.'
|
|
109
|
+
);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
return warningResult(`Unable to verify capture hook installation: ${error.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function checkRoutingConfigReadable(projectDir, { fs = require('fs') } = {}) {
|
|
116
|
+
const configPath = path.join(projectDir, '.tlc.json');
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
120
|
+
JSON.parse(content);
|
|
121
|
+
return passResult('Routing config is readable');
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return warningResult(`Unable to read valid JSON from .tlc.json. Fix the routing config. ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function checkProviderAvailable(projectDir, { fs = require('fs') } = {}) {
|
|
128
|
+
const statePath = path.join(projectDir, '.tlc', '.router-state.json');
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const content = fs.readFileSync(statePath, 'utf8');
|
|
132
|
+
const state = JSON.parse(content);
|
|
133
|
+
const availableCount = Number(state && state.summary && state.summary.available_count);
|
|
134
|
+
|
|
135
|
+
if (availableCount > 0) {
|
|
136
|
+
return passResult('At least one provider is available');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return warningResult(
|
|
140
|
+
'No provider available in .tlc/.router-state.json. Refresh router state or configure a provider.'
|
|
141
|
+
);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return warningResult(
|
|
144
|
+
`Unable to read provider availability from .tlc/.router-state.json. Refresh router state. ${error.message}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function countRecentTimestampedLines(content, now = Date.now()) {
|
|
150
|
+
const cutoff = now - DAY_IN_MS;
|
|
151
|
+
const lines = String(content || '')
|
|
152
|
+
.split(/\r?\n/)
|
|
153
|
+
.map((line) => line.trim())
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
|
|
156
|
+
let recentCount = 0;
|
|
157
|
+
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
const timestampMatch = line.match(
|
|
160
|
+
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/
|
|
161
|
+
);
|
|
162
|
+
if (!timestampMatch) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const timestamp = Date.parse(timestampMatch[0]);
|
|
167
|
+
if (!Number.isNaN(timestamp) && timestamp >= cutoff) {
|
|
168
|
+
recentCount += 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return recentCount;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function checkCaptureWarningsLog(projectDir, { fs = require('fs') } = {}) {
|
|
176
|
+
const warningsPath = path.join(projectDir, '.tlc', 'memory', '.capture-warnings.log');
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
if (!fs.existsSync(warningsPath)) {
|
|
180
|
+
return passResult('No recent capture warnings');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const content = fs.readFileSync(warningsPath, 'utf8');
|
|
184
|
+
const recentCount = countRecentTimestampedLines(content);
|
|
185
|
+
|
|
186
|
+
if (recentCount > 0) {
|
|
187
|
+
return warningResult(
|
|
188
|
+
`Found ${recentCount} capture warnings in the last 24h. Review .tlc/memory/.capture-warnings.log.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return passResult('No recent capture warnings');
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return warningResult(
|
|
195
|
+
`Unable to inspect .tlc/memory/.capture-warnings.log for recent warnings: ${error.message}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function runHealthChecks(
|
|
201
|
+
projectDir,
|
|
202
|
+
{
|
|
203
|
+
fs = require('fs'),
|
|
204
|
+
execSync = require('child_process').execSync,
|
|
205
|
+
} = {}
|
|
206
|
+
) {
|
|
207
|
+
const results = {
|
|
208
|
+
passed: [],
|
|
209
|
+
warnings: [],
|
|
210
|
+
autoFixed: [],
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const checks = [
|
|
214
|
+
() => ensureMemoryDirs(projectDir, { fs }),
|
|
215
|
+
() => checkGitignoreTeamMemory(projectDir, { execSync }),
|
|
216
|
+
() => checkSpoolEntries(projectDir, { fs }),
|
|
217
|
+
() => checkCaptureHookInstalled(projectDir, { fs }),
|
|
218
|
+
() => checkRoutingConfigReadable(projectDir, { fs }),
|
|
219
|
+
() => checkProviderAvailable(projectDir, { fs }),
|
|
220
|
+
() => checkCaptureWarningsLog(projectDir, { fs }),
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
for (const check of checks) {
|
|
224
|
+
try {
|
|
225
|
+
const result = await check();
|
|
226
|
+
if (result.passed) {
|
|
227
|
+
results.passed.push(result.passed);
|
|
228
|
+
}
|
|
229
|
+
if (result.warning) {
|
|
230
|
+
results.warnings.push(result.warning);
|
|
231
|
+
}
|
|
232
|
+
if (Array.isArray(result.autoFixed) && result.autoFixed.length > 0) {
|
|
233
|
+
results.autoFixed.push(...result.autoFixed);
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
results.warnings.push(
|
|
237
|
+
`[TLC WARNING] Health check execution failed unexpectedly: ${error.message}`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return results;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = {
|
|
246
|
+
ensureMemoryDirs,
|
|
247
|
+
checkGitignoreTeamMemory,
|
|
248
|
+
checkSpoolEntries,
|
|
249
|
+
checkCaptureHookInstalled,
|
|
250
|
+
checkRoutingConfigReadable,
|
|
251
|
+
checkProviderAvailable,
|
|
252
|
+
checkCaptureWarningsLog,
|
|
253
|
+
countRecentTimestampedLines,
|
|
254
|
+
runHealthChecks,
|
|
255
|
+
};
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const {
|
|
9
|
+
ensureMemoryDirs,
|
|
10
|
+
checkGitignoreTeamMemory,
|
|
11
|
+
checkSpoolEntries,
|
|
12
|
+
checkCaptureHookInstalled,
|
|
13
|
+
checkRoutingConfigReadable,
|
|
14
|
+
checkProviderAvailable,
|
|
15
|
+
checkCaptureWarningsLog,
|
|
16
|
+
runHealthChecks,
|
|
17
|
+
} = require('./health-check.js');
|
|
18
|
+
|
|
19
|
+
describe('health-check', () => {
|
|
20
|
+
let projectDir;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-health-check-'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('ensureMemoryDirs creates missing memory directories and reports autofixes', async () => {
|
|
31
|
+
const result = await ensureMemoryDirs(projectDir, { fs });
|
|
32
|
+
|
|
33
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions'))).toBe(true);
|
|
34
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas'))).toBe(true);
|
|
35
|
+
expect(fs.existsSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'))).toBe(true);
|
|
36
|
+
expect(result.passed).toBe('Memory directories ready');
|
|
37
|
+
expect(result.autoFixed).toHaveLength(3);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('ensureMemoryDirs does not autofix directories that already exist', async () => {
|
|
41
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', 'team', 'decisions'), { recursive: true });
|
|
42
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', 'team', 'gotchas'), { recursive: true });
|
|
43
|
+
fs.mkdirSync(path.join(projectDir, '.tlc', 'memory', '.local', 'sessions'), { recursive: true });
|
|
44
|
+
|
|
45
|
+
const result = await ensureMemoryDirs(projectDir, { fs });
|
|
46
|
+
|
|
47
|
+
expect(result.autoFixed).toEqual([]);
|
|
48
|
+
expect(result.passed).toBe('Memory directories ready');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('ensureMemoryDirs converts fs failures into warnings', async () => {
|
|
52
|
+
const failingFs = {
|
|
53
|
+
existsSync: vi.fn(() => false),
|
|
54
|
+
mkdirSync: vi.fn(() => {
|
|
55
|
+
throw new Error('mkdir failed');
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const result = await ensureMemoryDirs(projectDir, { fs: failingFs });
|
|
60
|
+
|
|
61
|
+
expect(result.passed).toBeNull();
|
|
62
|
+
expect(result.warning).toContain('[TLC WARNING]');
|
|
63
|
+
expect(result.warning).toContain('memory directories');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('checkGitignoreTeamMemory warns when team memory path is ignored', async () => {
|
|
67
|
+
const execSync = vi.fn(() => Buffer.from('.tlc/memory/team/test.md\n'));
|
|
68
|
+
|
|
69
|
+
const result = await checkGitignoreTeamMemory(projectDir, { execSync });
|
|
70
|
+
|
|
71
|
+
expect(execSync).toHaveBeenCalledWith(
|
|
72
|
+
'git check-ignore .tlc/memory/team/test.md 2>/dev/null',
|
|
73
|
+
expect.objectContaining({ cwd: projectDir })
|
|
74
|
+
);
|
|
75
|
+
expect(result.warning).toContain('[TLC WARNING]');
|
|
76
|
+
expect(result.warning).toContain('gitignore');
|
|
77
|
+
expect(result.passed).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('checkGitignoreTeamMemory passes when git check-ignore does not match', async () => {
|
|
81
|
+
const execSync = vi.fn(() => Buffer.from(''));
|
|
82
|
+
|
|
83
|
+
const result = await checkGitignoreTeamMemory(projectDir, { execSync });
|
|
84
|
+
|
|
85
|
+
expect(result.passed).toBe('Team memory is not ignored by git');
|
|
86
|
+
expect(result.warning).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('checkSpoolEntries warns with byte count when spool file has content', async () => {
|
|
90
|
+
const spoolPath = path.join(projectDir, '.tlc', 'memory', '.spool.jsonl');
|
|
91
|
+
fs.mkdirSync(path.dirname(spoolPath), { recursive: true });
|
|
92
|
+
fs.writeFileSync(spoolPath, '{"event":"queued"}\n');
|
|
93
|
+
|
|
94
|
+
const result = await checkSpoolEntries(projectDir, { fs });
|
|
95
|
+
|
|
96
|
+
expect(result.warning).toContain('[TLC WARNING]');
|
|
97
|
+
expect(result.warning).toContain('unprocessed spool entries');
|
|
98
|
+
expect(result.warning).toContain('19 bytes');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('checkSpoolEntries passes when spool file is missing or empty', async () => {
|
|
102
|
+
expect((await checkSpoolEntries(projectDir, { fs })).passed).toBe('No unprocessed spool entries');
|
|
103
|
+
|
|
104
|
+
const spoolPath = path.join(projectDir, '.tlc', 'memory', '.spool.jsonl');
|
|
105
|
+
fs.mkdirSync(path.dirname(spoolPath), { recursive: true });
|
|
106
|
+
fs.writeFileSync(spoolPath, '');
|
|
107
|
+
|
|
108
|
+
const result = await checkSpoolEntries(projectDir, { fs });
|
|
109
|
+
expect(result.passed).toBe('No unprocessed spool entries');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('checkCaptureHookInstalled warns when capture hook is missing', async () => {
|
|
113
|
+
const result = await checkCaptureHookInstalled(projectDir, { fs });
|
|
114
|
+
|
|
115
|
+
expect(result.warning).toContain('[TLC WARNING]');
|
|
116
|
+
expect(result.warning).toContain('capture hook');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('checkCaptureHookInstalled passes when capture hook exists', async () => {
|
|
120
|
+
const hookPath = path.join(projectDir, '.claude', 'hooks', 'tlc-capture-exchange.sh');
|
|
121
|
+
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
|
122
|
+
fs.writeFileSync(hookPath, '#!/bin/sh\n');
|
|
123
|
+
|
|
124
|
+
const result = await checkCaptureHookInstalled(projectDir, { fs });
|
|
125
|
+
|
|
126
|
+
expect(result.passed).toBe('Capture hook installed');
|
|
127
|
+
expect(result.warning).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('checkRoutingConfigReadable warns when .tlc.json is invalid JSON', async () => {
|
|
131
|
+
fs.writeFileSync(path.join(projectDir, '.tlc.json'), '{invalid');
|
|
132
|
+
|
|
133
|
+
const result = await checkRoutingConfigReadable(projectDir, { fs });
|
|
134
|
+
|
|
135
|
+
expect(result.warning).toContain('[TLC WARNING]');
|
|
136
|
+
expect(result.warning).toContain('.tlc.json');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('checkRoutingConfigReadable passes when .tlc.json can be parsed', async () => {
|
|
140
|
+
fs.writeFileSync(path.join(projectDir, '.tlc.json'), JSON.stringify({ ok: true }));
|
|
141
|
+
|
|
142
|
+
const result = await checkRoutingConfigReadable(projectDir, { fs });
|
|
143
|
+
|
|
144
|
+
expect(result.passed).toBe('Routing config is readable');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('checkProviderAvailable warns when router state has no available providers', async () => {
|
|
148
|
+
const statePath = path.join(projectDir, '.tlc', '.router-state.json');
|
|
149
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
150
|
+
fs.writeFileSync(statePath, JSON.stringify({ summary: { available_count: 0 } }));
|
|
151
|
+
|
|
152
|
+
const result = await checkProviderAvailable(projectDir, { fs });
|
|
153
|
+
|
|
154
|
+
expect(result.warning).toContain('[TLC WARNING]');
|
|
155
|
+
expect(result.warning).toContain('provider available');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('checkProviderAvailable passes when router state reports available providers', async () => {
|
|
159
|
+
const statePath = path.join(projectDir, '.tlc', '.router-state.json');
|
|
160
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
161
|
+
fs.writeFileSync(statePath, JSON.stringify({ summary: { available_count: 2 } }));
|
|
162
|
+
|
|
163
|
+
const result = await checkProviderAvailable(projectDir, { fs });
|
|
164
|
+
|
|
165
|
+
expect(result.passed).toBe('At least one provider is available');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('checkCaptureWarningsLog warns when recent capture warnings exist', async () => {
|
|
169
|
+
const warningsPath = path.join(projectDir, '.tlc', 'memory', '.capture-warnings.log');
|
|
170
|
+
fs.mkdirSync(path.dirname(warningsPath), { recursive: true });
|
|
171
|
+
fs.writeFileSync(warningsPath, `${new Date().toISOString()} capture lag detected\n`);
|
|
172
|
+
|
|
173
|
+
const result = await checkCaptureWarningsLog(projectDir, { fs });
|
|
174
|
+
|
|
175
|
+
expect(result.warning).toContain('[TLC WARNING]');
|
|
176
|
+
expect(result.warning).toContain('capture warnings');
|
|
177
|
+
expect(result.warning).toContain('last 24h');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('checkCaptureWarningsLog passes when warnings are older than 24 hours', async () => {
|
|
181
|
+
const warningsPath = path.join(projectDir, '.tlc', 'memory', '.capture-warnings.log');
|
|
182
|
+
const oldDate = new Date(Date.now() - (25 * 60 * 60 * 1000)).toISOString();
|
|
183
|
+
fs.mkdirSync(path.dirname(warningsPath), { recursive: true });
|
|
184
|
+
fs.writeFileSync(warningsPath, `${oldDate} old warning\n`);
|
|
185
|
+
|
|
186
|
+
const result = await checkCaptureWarningsLog(projectDir, { fs });
|
|
187
|
+
|
|
188
|
+
expect(result.passed).toBe('No recent capture warnings');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('runHealthChecks aggregates passed checks, warnings, and autofixes', async () => {
|
|
192
|
+
fs.writeFileSync(path.join(projectDir, '.tlc.json'), JSON.stringify({ ok: true }));
|
|
193
|
+
fs.mkdirSync(path.join(projectDir, '.claude', 'hooks'), { recursive: true });
|
|
194
|
+
fs.writeFileSync(
|
|
195
|
+
path.join(projectDir, '.claude', 'hooks', 'tlc-capture-exchange.sh'),
|
|
196
|
+
'#!/bin/sh\n'
|
|
197
|
+
);
|
|
198
|
+
fs.mkdirSync(path.join(projectDir, '.tlc'), { recursive: true });
|
|
199
|
+
fs.writeFileSync(
|
|
200
|
+
path.join(projectDir, '.tlc', '.router-state.json'),
|
|
201
|
+
JSON.stringify({ summary: { available_count: 1 } })
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const execSync = vi.fn(() => Buffer.from(''));
|
|
205
|
+
const result = await runHealthChecks(projectDir, { fs, execSync });
|
|
206
|
+
|
|
207
|
+
expect(result.passed).toContain('Memory directories ready');
|
|
208
|
+
expect(result.passed).toContain('Team memory is not ignored by git');
|
|
209
|
+
expect(result.passed).toContain('Capture hook installed');
|
|
210
|
+
expect(result.passed).toContain('Routing config is readable');
|
|
211
|
+
expect(result.passed).toContain('At least one provider is available');
|
|
212
|
+
expect(result.warnings).toEqual([]);
|
|
213
|
+
expect(result.autoFixed).toHaveLength(3);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('runHealthChecks never throws when dependencies fail and returns warnings instead', async () => {
|
|
217
|
+
const failingFs = {
|
|
218
|
+
existsSync: vi.fn(() => {
|
|
219
|
+
throw new Error('exists failed');
|
|
220
|
+
}),
|
|
221
|
+
mkdirSync: vi.fn(() => {
|
|
222
|
+
throw new Error('mkdir failed');
|
|
223
|
+
}),
|
|
224
|
+
readFileSync: vi.fn(() => {
|
|
225
|
+
throw new Error('read failed');
|
|
226
|
+
}),
|
|
227
|
+
statSync: vi.fn(() => {
|
|
228
|
+
throw new Error('stat failed');
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
const execSync = vi.fn(() => {
|
|
232
|
+
throw new Error('git failed');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const result = await runHealthChecks(projectDir, { fs: failingFs, execSync });
|
|
236
|
+
|
|
237
|
+
expect(Array.isArray(result.passed)).toBe(true);
|
|
238
|
+
expect(Array.isArray(result.warnings)).toBe(true);
|
|
239
|
+
expect(Array.isArray(result.autoFixed)).toBe(true);
|
|
240
|
+
expect(result.warnings.length).toBeGreaterThan(0);
|
|
241
|
+
expect(result.warnings.every((warning) => warning.startsWith('[TLC WARNING]'))).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -13,6 +13,10 @@ const DEFAULT_CONFIG = {
|
|
|
13
13
|
devserver: { url: null },
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
function isMissingConfigError(error) {
|
|
17
|
+
return error?.code === 'ENOENT' || error?.message?.includes('ENOENT');
|
|
18
|
+
}
|
|
19
|
+
|
|
16
20
|
export class ModelRouter {
|
|
17
21
|
constructor(config = {}) {
|
|
18
22
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
@@ -88,8 +92,13 @@ export class ModelRouter {
|
|
|
88
92
|
this.config = { ...DEFAULT_CONFIG, ...configData.router };
|
|
89
93
|
this.devserverUrl = configData.router.devserver?.url || null;
|
|
90
94
|
}
|
|
91
|
-
} catch {
|
|
92
|
-
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!isMissingConfigError(error)) {
|
|
97
|
+
const message = error instanceof SyntaxError
|
|
98
|
+
? 'Failed to parse .tlc.json router config; using defaults'
|
|
99
|
+
: `Failed to load .tlc.json router config; using defaults (${error?.message || 'unknown error'})`;
|
|
100
|
+
console.warn(`[TLC WARNING] ${message}`);
|
|
101
|
+
}
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
ModelRouter,
|
|
4
4
|
resolveProvider,
|
|
@@ -22,6 +22,10 @@ describe('Model Router', () => {
|
|
|
22
22
|
});
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
25
29
|
describe('resolveProvider', () => {
|
|
26
30
|
it('returns local when CLI detected', async () => {
|
|
27
31
|
router._detectCLI = vi.fn().mockResolvedValue({ found: true, path: '/usr/bin/claude' });
|
|
@@ -105,6 +109,28 @@ describe('Model Router', () => {
|
|
|
105
109
|
|
|
106
110
|
expect(router.config).toBeDefined();
|
|
107
111
|
});
|
|
112
|
+
|
|
113
|
+
it('warns when config JSON is invalid', async () => {
|
|
114
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
115
|
+
router._readConfig = vi.fn().mockRejectedValue(new SyntaxError('Unexpected token }'));
|
|
116
|
+
|
|
117
|
+
await router.loadConfig();
|
|
118
|
+
|
|
119
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
120
|
+
'[TLC WARNING] Failed to parse .tlc.json router config; using defaults'
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('stays silent when config file is missing', async () => {
|
|
125
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
126
|
+
const error = new Error('ENOENT');
|
|
127
|
+
error.code = 'ENOENT';
|
|
128
|
+
router._readConfig = vi.fn().mockRejectedValue(error);
|
|
129
|
+
|
|
130
|
+
await router.loadConfig();
|
|
131
|
+
|
|
132
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
108
134
|
});
|
|
109
135
|
|
|
110
136
|
describe('handleUnavailable', () => {
|