tlc-claude-code 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/tlc/audit.md +12 -0
- package/.claude/commands/tlc/autofix.md +12 -0
- package/.claude/commands/tlc/build.md +14 -2
- package/.claude/commands/tlc/cleanup.md +13 -1
- package/.claude/commands/tlc/coverage.md +12 -0
- package/.claude/commands/tlc/discuss.md +12 -0
- package/.claude/commands/tlc/docs.md +13 -1
- package/.claude/commands/tlc/edge-cases.md +13 -1
- package/.claude/commands/tlc/plan.md +12 -0
- package/.claude/commands/tlc/preflight.md +12 -0
- package/.claude/commands/tlc/refactor.md +12 -0
- package/.claude/commands/tlc/review-pr.md +13 -1
- package/.claude/commands/tlc/review.md +12 -0
- package/.claude/commands/tlc/security.md +13 -1
- package/.claude/commands/tlc/status.md +20 -1
- package/.claude/commands/tlc/verify.md +12 -0
- package/.claude/commands/tlc/watchci.md +12 -0
- package/package.json +1 -1
- package/server/lib/orchestration/completion-checker.js +52 -2
- package/server/lib/orchestration/completion-checker.test.js +64 -0
- package/server/lib/orchestration/session-status.js +28 -4
- package/server/lib/orchestration/session-status.test.js +44 -1
- package/server/lib/orchestration/skill-dispatcher.js +270 -0
- package/server/lib/orchestration/skill-dispatcher.test.js +449 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
checkHealth,
|
|
8
|
+
dispatch,
|
|
9
|
+
pollUntilDone,
|
|
10
|
+
captureResult,
|
|
11
|
+
} = require('./skill-dispatcher.js');
|
|
12
|
+
|
|
13
|
+
describe('skill-dispatcher', () => {
|
|
14
|
+
const tempDirs = [];
|
|
15
|
+
let originalAbortSignalTimeout;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
vi.useRealTimers();
|
|
20
|
+
originalAbortSignalTimeout = AbortSignal.timeout;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
AbortSignal.timeout = originalAbortSignalTimeout;
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
|
|
27
|
+
while (tempDirs.length > 0) {
|
|
28
|
+
fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function makeTempDir() {
|
|
33
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-dispatcher-test-'));
|
|
34
|
+
tempDirs.push(tempDir);
|
|
35
|
+
return tempDir;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeActiveSessionsPath() {
|
|
39
|
+
return path.join(makeTempDir(), '.tlc', '.active-sessions.json');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('checkHealth', () => {
|
|
43
|
+
it('returns availability with latency and uses AbortSignal.timeout', async () => {
|
|
44
|
+
const signal = { type: 'timeout-signal' };
|
|
45
|
+
const timeoutSpy = vi.fn().mockReturnValue(signal);
|
|
46
|
+
AbortSignal.timeout = timeoutSpy;
|
|
47
|
+
|
|
48
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
49
|
+
|
|
50
|
+
const result = await checkHealth({
|
|
51
|
+
orchestratorUrl: 'http://orchestrator.test/',
|
|
52
|
+
fetch,
|
|
53
|
+
timeout: 1500,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(timeoutSpy).toHaveBeenCalledWith(1500);
|
|
57
|
+
expect(fetch).toHaveBeenCalledWith('http://orchestrator.test/health', {
|
|
58
|
+
signal,
|
|
59
|
+
});
|
|
60
|
+
expect(result.available).toBe(true);
|
|
61
|
+
expect(result.latencyMs).toBeTypeOf('number');
|
|
62
|
+
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns unavailable when health endpoint returns non-ok status', async () => {
|
|
66
|
+
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
|
67
|
+
|
|
68
|
+
const result = await checkHealth({ fetch });
|
|
69
|
+
|
|
70
|
+
expect(result).toEqual({
|
|
71
|
+
available: false,
|
|
72
|
+
reason: 'health check failed with status 503',
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns unavailable reason for network errors', async () => {
|
|
77
|
+
const fetch = vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED'));
|
|
78
|
+
|
|
79
|
+
const result = await checkHealth({ fetch });
|
|
80
|
+
|
|
81
|
+
expect(result).toEqual({
|
|
82
|
+
available: false,
|
|
83
|
+
reason: 'connect ECONNREFUSED',
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('dispatch', () => {
|
|
89
|
+
it('posts to orchestrator and appends a session entry', async () => {
|
|
90
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
91
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
92
|
+
ok: true,
|
|
93
|
+
json: async () => ({ id: 'session-101' }),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await dispatch({
|
|
97
|
+
skill: 'design-review',
|
|
98
|
+
prompt: 'Review the architecture',
|
|
99
|
+
project: 'tlc',
|
|
100
|
+
provider: 'codex',
|
|
101
|
+
orchestratorUrl: 'http://orchestrator.test/',
|
|
102
|
+
activeSessionsPath,
|
|
103
|
+
fetch,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(fetch).toHaveBeenCalledWith('http://orchestrator.test/sessions', {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json' },
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
project: 'tlc',
|
|
111
|
+
pool: 'local-tmux',
|
|
112
|
+
command: 'codex',
|
|
113
|
+
prompt: 'Review the architecture',
|
|
114
|
+
}),
|
|
115
|
+
});
|
|
116
|
+
expect(result).toEqual({
|
|
117
|
+
sessionId: 'session-101',
|
|
118
|
+
dispatched: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const sessions = JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'));
|
|
122
|
+
expect(sessions).toEqual([
|
|
123
|
+
{
|
|
124
|
+
sessionId: 'session-101',
|
|
125
|
+
taskName: 'design-review',
|
|
126
|
+
startedAt: expect.any(String),
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('uses codex as the default provider', async () => {
|
|
132
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
133
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
134
|
+
ok: true,
|
|
135
|
+
json: async () => ({ sessionId: 'session-102' }),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await dispatch({
|
|
139
|
+
skill: 'qa',
|
|
140
|
+
prompt: 'Run QA checks',
|
|
141
|
+
project: 'tlc',
|
|
142
|
+
activeSessionsPath,
|
|
143
|
+
fetch,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
|
147
|
+
project: 'tlc',
|
|
148
|
+
pool: 'local-tmux',
|
|
149
|
+
command: 'codex',
|
|
150
|
+
prompt: 'Run QA checks',
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('creates the parent directory when missing', async () => {
|
|
155
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
156
|
+
const parentDir = path.dirname(activeSessionsPath);
|
|
157
|
+
fs.rmSync(parentDir, { recursive: true, force: true });
|
|
158
|
+
|
|
159
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
160
|
+
ok: true,
|
|
161
|
+
json: async () => ({ id: 'session-103' }),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await dispatch({
|
|
165
|
+
skill: 'lint',
|
|
166
|
+
prompt: 'Fix lint',
|
|
167
|
+
project: 'tlc',
|
|
168
|
+
activeSessionsPath,
|
|
169
|
+
fetch,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(fs.existsSync(parentDir)).toBe(true);
|
|
173
|
+
expect(fs.existsSync(activeSessionsPath)).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('appends to existing active sessions', async () => {
|
|
177
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
178
|
+
fs.mkdirSync(path.dirname(activeSessionsPath), { recursive: true });
|
|
179
|
+
fs.writeFileSync(activeSessionsPath, JSON.stringify([
|
|
180
|
+
{ sessionId: 'existing-1', taskName: 'existing', startedAt: '2026-04-03T00:00:00.000Z' },
|
|
181
|
+
], null, 2));
|
|
182
|
+
|
|
183
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
184
|
+
ok: true,
|
|
185
|
+
json: async () => ({ id: 'session-104' }),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await dispatch({
|
|
189
|
+
skill: 'security',
|
|
190
|
+
prompt: 'Scan security',
|
|
191
|
+
project: 'tlc',
|
|
192
|
+
activeSessionsPath,
|
|
193
|
+
fetch,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([
|
|
197
|
+
{ sessionId: 'existing-1', taskName: 'existing', startedAt: '2026-04-03T00:00:00.000Z' },
|
|
198
|
+
{ sessionId: 'session-104', taskName: 'security', startedAt: expect.any(String) },
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('resets corrupt active sessions JSON to an empty array before appending', async () => {
|
|
203
|
+
const activeSessionsPath = makeActiveSessionsPath();
|
|
204
|
+
fs.mkdirSync(path.dirname(activeSessionsPath), { recursive: true });
|
|
205
|
+
fs.writeFileSync(activeSessionsPath, '{this is not valid json');
|
|
206
|
+
|
|
207
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
208
|
+
ok: true,
|
|
209
|
+
json: async () => ({ id: 'session-105' }),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await dispatch({
|
|
213
|
+
skill: 'review',
|
|
214
|
+
prompt: 'Do review',
|
|
215
|
+
project: 'tlc',
|
|
216
|
+
activeSessionsPath,
|
|
217
|
+
fetch,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(JSON.parse(fs.readFileSync(activeSessionsPath, 'utf8'))).toEqual([
|
|
221
|
+
{ sessionId: 'session-105', taskName: 'review', startedAt: expect.any(String) },
|
|
222
|
+
]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('returns a failure reason when the orchestrator rejects the dispatch', async () => {
|
|
226
|
+
const fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 });
|
|
227
|
+
|
|
228
|
+
const result = await dispatch({
|
|
229
|
+
skill: 'review',
|
|
230
|
+
prompt: 'Do review',
|
|
231
|
+
project: 'tlc',
|
|
232
|
+
fetch,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result).toEqual({
|
|
236
|
+
dispatched: false,
|
|
237
|
+
reason: 'dispatch failed with status 500',
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('returns a failure reason for invalid session payloads', async () => {
|
|
242
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
243
|
+
ok: true,
|
|
244
|
+
json: async () => ({ ok: true }),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const result = await dispatch({
|
|
248
|
+
skill: 'review',
|
|
249
|
+
prompt: 'Do review',
|
|
250
|
+
project: 'tlc',
|
|
251
|
+
fetch,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(result).toEqual({
|
|
255
|
+
dispatched: false,
|
|
256
|
+
reason: 'invalid session response',
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('returns a failure reason for network errors', async () => {
|
|
261
|
+
const fetch = vi.fn().mockRejectedValue(new Error('socket hang up'));
|
|
262
|
+
|
|
263
|
+
const result = await dispatch({
|
|
264
|
+
skill: 'review',
|
|
265
|
+
prompt: 'Do review',
|
|
266
|
+
project: 'tlc',
|
|
267
|
+
fetch,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
expect(result).toEqual({
|
|
271
|
+
dispatched: false,
|
|
272
|
+
reason: 'socket hang up',
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('pollUntilDone', () => {
|
|
278
|
+
it('resolves when the session reaches a terminal state', async () => {
|
|
279
|
+
vi.useFakeTimers();
|
|
280
|
+
|
|
281
|
+
const fetch = vi.fn()
|
|
282
|
+
.mockResolvedValueOnce({
|
|
283
|
+
ok: true,
|
|
284
|
+
json: async () => ({ status: 'running', result: { progress: 10 } }),
|
|
285
|
+
})
|
|
286
|
+
.mockResolvedValueOnce({
|
|
287
|
+
ok: true,
|
|
288
|
+
json: async () => ({ status: 'completed', result: { summary: 'done' } }),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const pending = pollUntilDone('session-201', {
|
|
292
|
+
orchestratorUrl: 'http://orchestrator.test/',
|
|
293
|
+
interval: 1000,
|
|
294
|
+
timeout: 10000,
|
|
295
|
+
fetch,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
await vi.runAllTimersAsync();
|
|
299
|
+
|
|
300
|
+
await expect(pending).resolves.toEqual({
|
|
301
|
+
status: 'completed',
|
|
302
|
+
result: { summary: 'done' },
|
|
303
|
+
});
|
|
304
|
+
expect(fetch).toHaveBeenNthCalledWith(
|
|
305
|
+
1,
|
|
306
|
+
'http://orchestrator.test/sessions/session-201/status'
|
|
307
|
+
);
|
|
308
|
+
expect(fetch).toHaveBeenNthCalledWith(
|
|
309
|
+
2,
|
|
310
|
+
'http://orchestrator.test/sessions/session-201/status'
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('treats intervals below 1000ms as 1000ms', async () => {
|
|
315
|
+
vi.useFakeTimers();
|
|
316
|
+
|
|
317
|
+
const fetch = vi.fn()
|
|
318
|
+
.mockResolvedValueOnce({
|
|
319
|
+
ok: true,
|
|
320
|
+
json: async () => ({ status: 'running' }),
|
|
321
|
+
})
|
|
322
|
+
.mockResolvedValueOnce({
|
|
323
|
+
ok: true,
|
|
324
|
+
json: async () => ({ status: 'failed', result: { reason: 'boom' } }),
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
const pending = pollUntilDone('session-202', {
|
|
328
|
+
orchestratorUrl: 'http://orchestrator.test',
|
|
329
|
+
interval: 100,
|
|
330
|
+
timeout: 10000,
|
|
331
|
+
fetch,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
335
|
+
await vi.advanceTimersByTimeAsync(999);
|
|
336
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
337
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
338
|
+
await expect(pending).resolves.toEqual({
|
|
339
|
+
status: 'failed',
|
|
340
|
+
result: { reason: 'boom' },
|
|
341
|
+
});
|
|
342
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('returns top-level payload as result when result field is missing', async () => {
|
|
346
|
+
vi.useFakeTimers();
|
|
347
|
+
|
|
348
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
349
|
+
ok: true,
|
|
350
|
+
json: async () => ({ status: 'loop_detected', summary: 'stopped looping' }),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
await expect(pollUntilDone('session-203', { fetch })).resolves.toEqual({
|
|
354
|
+
status: 'loop_detected',
|
|
355
|
+
result: { status: 'loop_detected', summary: 'stopped looping' },
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('rejects with Poll timeout when the session never reaches a terminal state', async () => {
|
|
360
|
+
vi.useFakeTimers();
|
|
361
|
+
|
|
362
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
363
|
+
ok: true,
|
|
364
|
+
json: async () => ({ status: 'running' }),
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const pending = pollUntilDone('session-204', {
|
|
368
|
+
fetch,
|
|
369
|
+
interval: 1000,
|
|
370
|
+
timeout: 2500,
|
|
371
|
+
});
|
|
372
|
+
const assertion = expect(pending).rejects.toThrow('Poll timeout');
|
|
373
|
+
|
|
374
|
+
await vi.runAllTimersAsync();
|
|
375
|
+
|
|
376
|
+
await assertion;
|
|
377
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe('captureResult', () => {
|
|
382
|
+
it('returns output, exitCode, and summary from the status payload', async () => {
|
|
383
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
384
|
+
status: 200,
|
|
385
|
+
json: async () => ({
|
|
386
|
+
status: 'completed',
|
|
387
|
+
result: {
|
|
388
|
+
paneSnapshot: 'build output',
|
|
389
|
+
exitCode: 0,
|
|
390
|
+
summary: 'tests passed',
|
|
391
|
+
},
|
|
392
|
+
}),
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const result = await captureResult('session-301', {
|
|
396
|
+
orchestratorUrl: 'http://orchestrator.test/',
|
|
397
|
+
fetch,
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
expect(fetch).toHaveBeenCalledWith('http://orchestrator.test/sessions/session-301/status');
|
|
401
|
+
expect(result).toEqual({
|
|
402
|
+
output: 'build output',
|
|
403
|
+
exitCode: 0,
|
|
404
|
+
summary: 'tests passed',
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('returns output null for 404 responses', async () => {
|
|
409
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
410
|
+
status: 404,
|
|
411
|
+
json: async () => ({ message: 'missing' }),
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const result = await captureResult('session-302', { fetch });
|
|
415
|
+
|
|
416
|
+
expect(result).toEqual({ output: null });
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('reads top-level paneSnapshot fields when result is not nested', async () => {
|
|
420
|
+
const fetch = vi.fn().mockResolvedValue({
|
|
421
|
+
status: 200,
|
|
422
|
+
json: async () => ({
|
|
423
|
+
paneSnapshot: 'stdout',
|
|
424
|
+
exitCode: 17,
|
|
425
|
+
summary: 'failed',
|
|
426
|
+
}),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const result = await captureResult('session-303', { fetch });
|
|
430
|
+
|
|
431
|
+
expect(result).toEqual({
|
|
432
|
+
output: 'stdout',
|
|
433
|
+
exitCode: 17,
|
|
434
|
+
summary: 'failed',
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('returns output null and the error message on network failure', async () => {
|
|
439
|
+
const fetch = vi.fn().mockRejectedValue(new Error('connect ETIMEDOUT'));
|
|
440
|
+
|
|
441
|
+
const result = await captureResult('session-304', { fetch });
|
|
442
|
+
|
|
443
|
+
expect(result).toEqual({
|
|
444
|
+
output: null,
|
|
445
|
+
error: 'connect ETIMEDOUT',
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
});
|