tlc-claude-code 2.4.3 → 2.4.5
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 +33 -13
- 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/hooks/tlc-capture-exchange.sh +50 -21
- package/.claude/hooks/tlc-session-init.sh +30 -0
- package/bin/init.js +12 -3
- package/package.json +1 -1
- 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/orchestration/cli-dispatch.js +200 -0
- package/server/lib/orchestration/cli-dispatch.test.js +242 -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/task-router-config.js +22 -5
- package/server/lib/task-router-config.test.js +46 -13
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
let parseCodexEvents = null;
|
|
2
|
+
|
|
3
|
+
try {
|
|
4
|
+
({ parseCodexEvents } = require('../capture/codex-event-parser.js'));
|
|
5
|
+
} catch (_) {
|
|
6
|
+
parseCodexEvents = null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function fallbackParseCodexEvents(jsonStream) {
|
|
10
|
+
const lines = typeof jsonStream === 'string' ? jsonStream.split('\n') : [];
|
|
11
|
+
let threadId = null;
|
|
12
|
+
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
if (typeof line !== 'string' || line.trim() === '') {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const event = JSON.parse(line);
|
|
20
|
+
if (
|
|
21
|
+
event &&
|
|
22
|
+
event.type === 'thread.started' &&
|
|
23
|
+
typeof event.thread_id === 'string' &&
|
|
24
|
+
event.thread_id.trim() !== ''
|
|
25
|
+
) {
|
|
26
|
+
threadId = event.thread_id;
|
|
27
|
+
}
|
|
28
|
+
} catch (_) {
|
|
29
|
+
// Ignore non-JSON lines in mixed stdout streams.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { threadId };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function extractCodexThreadId(stdout) {
|
|
37
|
+
const parsed = parseCodexEvents
|
|
38
|
+
? parseCodexEvents(stdout)
|
|
39
|
+
: fallbackParseCodexEvents(stdout);
|
|
40
|
+
|
|
41
|
+
return parsed && typeof parsed.threadId === 'string' ? parsed.threadId : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildCommand(provider, prompt, worktreePath, flags) {
|
|
45
|
+
const normalizedProvider = String(provider || '').trim();
|
|
46
|
+
const baseFlags = Array.isArray(flags) ? [...flags] : [];
|
|
47
|
+
const spawnOptions = {};
|
|
48
|
+
let usesStdin = true;
|
|
49
|
+
let args;
|
|
50
|
+
|
|
51
|
+
if (normalizedProvider === 'codex') {
|
|
52
|
+
args = ['exec', '--json', '--full-auto'];
|
|
53
|
+
if (worktreePath) {
|
|
54
|
+
args.push('-C', worktreePath);
|
|
55
|
+
}
|
|
56
|
+
} else if (normalizedProvider === 'gemini') {
|
|
57
|
+
usesStdin = false;
|
|
58
|
+
args = [...baseFlags, '-p', prompt];
|
|
59
|
+
if (worktreePath) {
|
|
60
|
+
spawnOptions.cwd = worktreePath;
|
|
61
|
+
}
|
|
62
|
+
return { command: normalizedProvider, args, spawnOptions, usesStdin };
|
|
63
|
+
} else if (normalizedProvider === 'claude') {
|
|
64
|
+
usesStdin = false;
|
|
65
|
+
args = ['--agent', 'builder'];
|
|
66
|
+
if (worktreePath) {
|
|
67
|
+
args.push('--worktree', worktreePath);
|
|
68
|
+
spawnOptions.cwd = worktreePath;
|
|
69
|
+
}
|
|
70
|
+
args.push(...baseFlags, '-p', prompt);
|
|
71
|
+
return { command: normalizedProvider, args, spawnOptions, usesStdin };
|
|
72
|
+
} else if (normalizedProvider === 'ollama') {
|
|
73
|
+
args = ['run', ...baseFlags];
|
|
74
|
+
if (worktreePath) {
|
|
75
|
+
spawnOptions.cwd = worktreePath;
|
|
76
|
+
}
|
|
77
|
+
return { command: normalizedProvider, args, spawnOptions, usesStdin };
|
|
78
|
+
} else {
|
|
79
|
+
args = baseFlags;
|
|
80
|
+
if (worktreePath) {
|
|
81
|
+
spawnOptions.cwd = worktreePath;
|
|
82
|
+
}
|
|
83
|
+
return { command: normalizedProvider, args, spawnOptions, usesStdin };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
args.push(...baseFlags);
|
|
87
|
+
return { command: normalizedProvider, args, spawnOptions, usesStdin };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildSpawnError(provider, err) {
|
|
91
|
+
const message = err && err.message ? err.message : `Failed to spawn ${provider}`;
|
|
92
|
+
|
|
93
|
+
if (err && err.code === 'ENOENT') {
|
|
94
|
+
return {
|
|
95
|
+
code: 'CLI_NOT_FOUND',
|
|
96
|
+
message: `CLI "${provider}" was not found: ${message}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
code: 'SPAWN_ERROR',
|
|
102
|
+
message,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function dispatch({
|
|
107
|
+
provider,
|
|
108
|
+
prompt = '',
|
|
109
|
+
worktreePath,
|
|
110
|
+
flags = [],
|
|
111
|
+
timeout = 120000,
|
|
112
|
+
spawn = require('child_process').spawn,
|
|
113
|
+
binaryOverride,
|
|
114
|
+
}) {
|
|
115
|
+
const start = Date.now();
|
|
116
|
+
const built = buildCommand(provider, prompt, worktreePath, flags);
|
|
117
|
+
// Allow standalone to preserve custom/absolute binary paths
|
|
118
|
+
const command = binaryOverride || built.command;
|
|
119
|
+
const { args, spawnOptions, usesStdin } = built;
|
|
120
|
+
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
let proc;
|
|
123
|
+
let stdout = '';
|
|
124
|
+
let stderr = '';
|
|
125
|
+
let settled = false;
|
|
126
|
+
let timer = null;
|
|
127
|
+
|
|
128
|
+
function finish(exitCode, error) {
|
|
129
|
+
if (settled) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
settled = true;
|
|
134
|
+
if (timer) {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
resolve({
|
|
139
|
+
stdout,
|
|
140
|
+
stderr,
|
|
141
|
+
exitCode,
|
|
142
|
+
duration: Date.now() - start,
|
|
143
|
+
threadId: command === 'codex' ? extractCodexThreadId(stdout) : null,
|
|
144
|
+
error: error || null,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
proc = spawn(command, args, spawnOptions);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
finish(-1, buildSpawnError(command, err));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
proc.stdout.on('data', (chunk) => {
|
|
156
|
+
stdout += chunk.toString();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
proc.stderr.on('data', (chunk) => {
|
|
160
|
+
stderr += chunk.toString();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
proc.on('error', (err) => {
|
|
164
|
+
finish(-1, buildSpawnError(command, err));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
proc.on('close', (code) => {
|
|
168
|
+
finish(typeof code === 'number' ? code : -1, null);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
timer = setTimeout(() => {
|
|
172
|
+
if (typeof proc.kill === 'function') {
|
|
173
|
+
proc.kill();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
finish(-1, {
|
|
177
|
+
code: 'TIMEOUT',
|
|
178
|
+
message: `CLI "${command}" timed out after ${timeout}ms`,
|
|
179
|
+
});
|
|
180
|
+
}, timeout);
|
|
181
|
+
|
|
182
|
+
if (proc.stdin && typeof proc.stdin.on === 'function') {
|
|
183
|
+
proc.stdin.on('error', () => {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (proc.stdin) {
|
|
187
|
+
if (usesStdin && typeof proc.stdin.write === 'function' && prompt) {
|
|
188
|
+
proc.stdin.write(prompt);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (typeof proc.stdin.end === 'function') {
|
|
192
|
+
proc.stdin.end();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
dispatch,
|
|
200
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
|
|
4
|
+
const { dispatch } = require('./cli-dispatch.js');
|
|
5
|
+
|
|
6
|
+
function createMockProcess({ stdout = '', stderr = '', exitCode = 0, delay = 0 } = {}) {
|
|
7
|
+
const proc = new EventEmitter();
|
|
8
|
+
proc.stdout = new EventEmitter();
|
|
9
|
+
proc.stderr = new EventEmitter();
|
|
10
|
+
proc.stdin = {
|
|
11
|
+
write: vi.fn(),
|
|
12
|
+
end: vi.fn(),
|
|
13
|
+
on: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
proc.kill = vi.fn();
|
|
16
|
+
|
|
17
|
+
setTimeout(() => {
|
|
18
|
+
if (stdout) {
|
|
19
|
+
proc.stdout.emit('data', Buffer.from(stdout));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (stderr) {
|
|
23
|
+
proc.stderr.emit('data', Buffer.from(stderr));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
proc.emit('close', exitCode);
|
|
27
|
+
}, delay);
|
|
28
|
+
|
|
29
|
+
return proc;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('cli-dispatch', () => {
|
|
33
|
+
it('builds codex args with -C, writes prompt to stdin, and extracts threadId', async () => {
|
|
34
|
+
const stdout = [
|
|
35
|
+
'{"type":"thread.started","thread_id":"thread-123"}',
|
|
36
|
+
'{"type":"agent_message","message":{"items":[{"type":"text","text":"done"}]}}',
|
|
37
|
+
].join('\n');
|
|
38
|
+
const proc = createMockProcess({ stdout });
|
|
39
|
+
const spawn = vi.fn(() => proc);
|
|
40
|
+
|
|
41
|
+
const result = await dispatch({
|
|
42
|
+
provider: 'codex',
|
|
43
|
+
prompt: 'Review this change',
|
|
44
|
+
worktreePath: '/tmp/worktree',
|
|
45
|
+
flags: ['--model', 'gpt-5.4'],
|
|
46
|
+
spawn,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
50
|
+
'codex',
|
|
51
|
+
['exec', '--json', '--full-auto', '-C', '/tmp/worktree', '--model', 'gpt-5.4'],
|
|
52
|
+
{}
|
|
53
|
+
);
|
|
54
|
+
expect(proc.stdin.write).toHaveBeenCalledWith('Review this change');
|
|
55
|
+
expect(proc.stdin.end).toHaveBeenCalled();
|
|
56
|
+
expect(result.threadId).toBe('thread-123');
|
|
57
|
+
expect(result.exitCode).toBe(0);
|
|
58
|
+
expect(result.error).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('passes gemini prompt as -p argument and sets cwd from worktreePath', async () => {
|
|
62
|
+
const proc = createMockProcess({ stdout: 'gemini output' });
|
|
63
|
+
const spawn = vi.fn(() => proc);
|
|
64
|
+
|
|
65
|
+
const result = await dispatch({
|
|
66
|
+
provider: 'gemini',
|
|
67
|
+
prompt: 'Explain this diff',
|
|
68
|
+
worktreePath: '/tmp/gemini-worktree',
|
|
69
|
+
flags: ['--model', 'gemini-2.5-pro'],
|
|
70
|
+
spawn,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
74
|
+
'gemini',
|
|
75
|
+
['--model', 'gemini-2.5-pro', '-p', 'Explain this diff'],
|
|
76
|
+
{ cwd: '/tmp/gemini-worktree' }
|
|
77
|
+
);
|
|
78
|
+
expect(proc.stdin.write).not.toHaveBeenCalled();
|
|
79
|
+
expect(proc.stdin.end).toHaveBeenCalled();
|
|
80
|
+
expect(result.stdout).toBe('gemini output');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('builds claude args with worktree and prompt flag and sets cwd', async () => {
|
|
84
|
+
const proc = createMockProcess({ stdout: 'claude output' });
|
|
85
|
+
const spawn = vi.fn(() => proc);
|
|
86
|
+
|
|
87
|
+
await dispatch({
|
|
88
|
+
provider: 'claude',
|
|
89
|
+
prompt: 'Implement the feature',
|
|
90
|
+
worktreePath: '/tmp/claude-worktree',
|
|
91
|
+
flags: ['--permission-mode', 'auto'],
|
|
92
|
+
spawn,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
96
|
+
'claude',
|
|
97
|
+
['--agent', 'builder', '--worktree', '/tmp/claude-worktree', '--permission-mode', 'auto', '-p', 'Implement the feature'],
|
|
98
|
+
{ cwd: '/tmp/claude-worktree' }
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('uses stdin prompt for ollama and sets cwd when worktreePath is provided', async () => {
|
|
103
|
+
const proc = createMockProcess({ stdout: 'ollama output' });
|
|
104
|
+
const spawn = vi.fn(() => proc);
|
|
105
|
+
|
|
106
|
+
await dispatch({
|
|
107
|
+
provider: 'ollama',
|
|
108
|
+
prompt: 'Summarize the logs',
|
|
109
|
+
worktreePath: '/tmp/ollama-worktree',
|
|
110
|
+
flags: ['llama3.2'],
|
|
111
|
+
spawn,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
115
|
+
'ollama',
|
|
116
|
+
['run', 'llama3.2'],
|
|
117
|
+
{ cwd: '/tmp/ollama-worktree' }
|
|
118
|
+
);
|
|
119
|
+
expect(proc.stdin.write).toHaveBeenCalledWith('Summarize the logs');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('uses generic flags and stdin for unknown providers', async () => {
|
|
123
|
+
const proc = createMockProcess({ stdout: 'custom output' });
|
|
124
|
+
const spawn = vi.fn(() => proc);
|
|
125
|
+
|
|
126
|
+
await dispatch({
|
|
127
|
+
provider: 'custom-cli',
|
|
128
|
+
prompt: 'Do the custom thing',
|
|
129
|
+
worktreePath: '/tmp/custom-worktree',
|
|
130
|
+
flags: ['--mode', 'fast'],
|
|
131
|
+
spawn,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
135
|
+
'custom-cli',
|
|
136
|
+
['--mode', 'fast'],
|
|
137
|
+
{ cwd: '/tmp/custom-worktree' }
|
|
138
|
+
);
|
|
139
|
+
expect(proc.stdin.write).toHaveBeenCalledWith('Do the custom thing');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns CLI_NOT_FOUND when spawn emits ENOENT', async () => {
|
|
143
|
+
const proc = new EventEmitter();
|
|
144
|
+
proc.stdout = new EventEmitter();
|
|
145
|
+
proc.stderr = new EventEmitter();
|
|
146
|
+
proc.stdin = {
|
|
147
|
+
write: vi.fn(),
|
|
148
|
+
end: vi.fn(),
|
|
149
|
+
on: vi.fn(),
|
|
150
|
+
};
|
|
151
|
+
proc.kill = vi.fn();
|
|
152
|
+
|
|
153
|
+
const spawn = vi.fn(() => {
|
|
154
|
+
setTimeout(() => {
|
|
155
|
+
proc.emit('error', Object.assign(new Error('spawn codex ENOENT'), { code: 'ENOENT' }));
|
|
156
|
+
}, 0);
|
|
157
|
+
return proc;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const result = await dispatch({
|
|
161
|
+
provider: 'codex',
|
|
162
|
+
prompt: 'test',
|
|
163
|
+
spawn,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(result.exitCode).toBe(-1);
|
|
167
|
+
expect(result.error).toEqual(
|
|
168
|
+
expect.objectContaining({
|
|
169
|
+
code: 'CLI_NOT_FOUND',
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
expect(result.error.message).toContain('codex');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('returns TIMEOUT on hung processes', async () => {
|
|
176
|
+
const proc = createMockProcess({ stdout: 'late output', delay: 5000 });
|
|
177
|
+
const spawn = vi.fn(() => proc);
|
|
178
|
+
|
|
179
|
+
const result = await dispatch({
|
|
180
|
+
provider: 'ollama',
|
|
181
|
+
prompt: 'slow task',
|
|
182
|
+
timeout: 20,
|
|
183
|
+
spawn,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(result.exitCode).toBe(-1);
|
|
187
|
+
expect(result.error).toEqual(
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
code: 'TIMEOUT',
|
|
190
|
+
})
|
|
191
|
+
);
|
|
192
|
+
expect(proc.kill).toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('captures non-zero exits without throwing', async () => {
|
|
196
|
+
const proc = createMockProcess({ stdout: 'partial', stderr: 'failed', exitCode: 2 });
|
|
197
|
+
const spawn = vi.fn(() => proc);
|
|
198
|
+
|
|
199
|
+
const result = await dispatch({
|
|
200
|
+
provider: 'custom-cli',
|
|
201
|
+
prompt: 'run',
|
|
202
|
+
spawn,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(result.stdout).toBe('partial');
|
|
206
|
+
expect(result.stderr).toBe('failed');
|
|
207
|
+
expect(result.exitCode).toBe(2);
|
|
208
|
+
expect(result.error).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('does not set cwd when codex already uses -C', async () => {
|
|
212
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
213
|
+
const spawn = vi.fn(() => proc);
|
|
214
|
+
|
|
215
|
+
await dispatch({
|
|
216
|
+
provider: 'codex',
|
|
217
|
+
prompt: 'test',
|
|
218
|
+
worktreePath: '/tmp/codex-worktree',
|
|
219
|
+
spawn,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
223
|
+
'codex',
|
|
224
|
+
['exec', '--json', '--full-auto', '-C', '/tmp/codex-worktree'],
|
|
225
|
+
{}
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('returns null threadId when codex output does not contain one', async () => {
|
|
230
|
+
const proc = createMockProcess({ stdout: '{"type":"agent_message"}' });
|
|
231
|
+
const spawn = vi.fn(() => proc);
|
|
232
|
+
|
|
233
|
+
const result = await dispatch({
|
|
234
|
+
provider: 'codex',
|
|
235
|
+
prompt: 'test',
|
|
236
|
+
spawn,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(result.threadId).toBeNull();
|
|
240
|
+
expect(result.exitCode).toBe(0);
|
|
241
|
+
});
|
|
242
|
+
});
|