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,221 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { dispatchToCodex, resumeSession } from './codex-orchestrator.js';
|
|
3
|
+
|
|
4
|
+
function createMockSpawn({
|
|
5
|
+
stdoutChunks = [],
|
|
6
|
+
stderrChunks = [],
|
|
7
|
+
exitCode = 0,
|
|
8
|
+
delay = 0,
|
|
9
|
+
error = null,
|
|
10
|
+
} = {}) {
|
|
11
|
+
return vi.fn(() => {
|
|
12
|
+
const listeners = {};
|
|
13
|
+
const stdinChunks = [];
|
|
14
|
+
|
|
15
|
+
const proc = {
|
|
16
|
+
stdin: {
|
|
17
|
+
write(data) { stdinChunks.push(data); },
|
|
18
|
+
end() { stdinChunks.push(null); },
|
|
19
|
+
on(event, cb) { listeners[`stdin:${event}`] = cb; },
|
|
20
|
+
},
|
|
21
|
+
stdout: {
|
|
22
|
+
on(event, cb) { listeners[`stdout:${event}`] = cb; },
|
|
23
|
+
},
|
|
24
|
+
stderr: {
|
|
25
|
+
on(event, cb) { listeners[`stderr:${event}`] = cb; },
|
|
26
|
+
},
|
|
27
|
+
on(event, cb) { listeners[event] = cb; },
|
|
28
|
+
kill: vi.fn(() => {
|
|
29
|
+
listeners._killed = true;
|
|
30
|
+
}),
|
|
31
|
+
_stdinChunks: stdinChunks,
|
|
32
|
+
_listeners: listeners,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
if (error) {
|
|
37
|
+
listeners.error?.(error);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
stdoutChunks.forEach((chunk) => {
|
|
42
|
+
listeners['stdout:data']?.(Buffer.from(chunk));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
stderrChunks.forEach((chunk) => {
|
|
46
|
+
listeners['stderr:data']?.(Buffer.from(chunk));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!listeners._killed) {
|
|
50
|
+
listeners.close?.(exitCode);
|
|
51
|
+
}
|
|
52
|
+
}, delay);
|
|
53
|
+
|
|
54
|
+
return proc;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('codex-orchestrator', () => {
|
|
59
|
+
describe('dispatchToCodex', () => {
|
|
60
|
+
it('builds correct command args', async () => {
|
|
61
|
+
const spawn = createMockSpawn();
|
|
62
|
+
|
|
63
|
+
await dispatchToCodex({
|
|
64
|
+
worktreePath: '/tmp/worktree',
|
|
65
|
+
prompt: 'Implement the task',
|
|
66
|
+
spawn,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
70
|
+
'codex',
|
|
71
|
+
['exec', '--json', '--full-auto', '-C', '/tmp/worktree'],
|
|
72
|
+
{}
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('pipes prompt via stdin', async () => {
|
|
77
|
+
const spawn = createMockSpawn();
|
|
78
|
+
|
|
79
|
+
await dispatchToCodex({
|
|
80
|
+
worktreePath: '/tmp/worktree',
|
|
81
|
+
prompt: 'Review this change',
|
|
82
|
+
spawn,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const proc = spawn.mock.results[0].value;
|
|
86
|
+
expect(proc._stdinChunks).toContain('Review this change');
|
|
87
|
+
expect(proc._stdinChunks).toContain(null);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('extracts thread_id from JSON stream', async () => {
|
|
91
|
+
const spawn = createMockSpawn({
|
|
92
|
+
stdoutChunks: [
|
|
93
|
+
'{"type":"status","message":"booting"}\n',
|
|
94
|
+
'{"type":"thread.started","thread_id":"thread_123"}\n',
|
|
95
|
+
'{"type":"message.delta","delta":"done"}\n',
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const result = await dispatchToCodex({
|
|
100
|
+
worktreePath: '/tmp/worktree',
|
|
101
|
+
prompt: 'Do work',
|
|
102
|
+
spawn,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.threadId).toBe('thread_123');
|
|
106
|
+
expect(result.stdout).toContain('"thread_id":"thread_123"');
|
|
107
|
+
expect(result.exitCode).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('ignores invalid JSON lines and still extracts thread_id', async () => {
|
|
111
|
+
const spawn = createMockSpawn({
|
|
112
|
+
stdoutChunks: [
|
|
113
|
+
'not-json\n',
|
|
114
|
+
'{"type":"thread.started","thread_id":"thread_abc"}\n',
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const result = await dispatchToCodex({
|
|
119
|
+
worktreePath: '/tmp/worktree',
|
|
120
|
+
prompt: 'Do work',
|
|
121
|
+
spawn,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result.threadId).toBe('thread_abc');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('timeout kills process and returns error', async () => {
|
|
128
|
+
const spawn = createMockSpawn({ delay: 1000 });
|
|
129
|
+
|
|
130
|
+
const result = await dispatchToCodex({
|
|
131
|
+
worktreePath: '/tmp/worktree',
|
|
132
|
+
prompt: 'Slow task',
|
|
133
|
+
timeout: 25,
|
|
134
|
+
spawn,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const proc = spawn.mock.results[0].value;
|
|
138
|
+
expect(proc.kill).toHaveBeenCalledTimes(1);
|
|
139
|
+
expect(result.exitCode).toBe(-1);
|
|
140
|
+
expect(result.error).toEqual(
|
|
141
|
+
expect.objectContaining({ code: 'PROCESS_TIMEOUT' })
|
|
142
|
+
);
|
|
143
|
+
expect(result.stderr).toContain('timed out');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('missing codex CLI returns structured error', async () => {
|
|
147
|
+
const spawn = createMockSpawn({
|
|
148
|
+
error: Object.assign(new Error('spawn codex ENOENT'), { code: 'ENOENT' }),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const result = await dispatchToCodex({
|
|
152
|
+
worktreePath: '/tmp/worktree',
|
|
153
|
+
prompt: 'Task',
|
|
154
|
+
spawn,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result.exitCode).toBe(-1);
|
|
158
|
+
expect(result.threadId).toBeNull();
|
|
159
|
+
expect(result.error).toEqual({
|
|
160
|
+
code: 'CODEX_CLI_NOT_FOUND',
|
|
161
|
+
message: 'Codex CLI is not installed or not available on PATH',
|
|
162
|
+
});
|
|
163
|
+
expect(result.stderr).toContain('ENOENT');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('captures non-zero exit without throwing', async () => {
|
|
167
|
+
const spawn = createMockSpawn({
|
|
168
|
+
stderrChunks: ['process failed'],
|
|
169
|
+
exitCode: 17,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const result = await dispatchToCodex({
|
|
173
|
+
worktreePath: '/tmp/worktree',
|
|
174
|
+
prompt: 'Task',
|
|
175
|
+
spawn,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(result.exitCode).toBe(17);
|
|
179
|
+
expect(result.stderr).toBe('process failed');
|
|
180
|
+
expect(result.error).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('resumeSession', () => {
|
|
185
|
+
it('builds correct resume command', async () => {
|
|
186
|
+
const spawn = createMockSpawn();
|
|
187
|
+
|
|
188
|
+
await resumeSession({
|
|
189
|
+
threadId: 'thread_123',
|
|
190
|
+
prompt: 'Continue',
|
|
191
|
+
spawn,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
195
|
+
'codex',
|
|
196
|
+
['exec', 'resume', 'thread_123', 'Continue'],
|
|
197
|
+
{}
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('passes prompt correctly', async () => {
|
|
202
|
+
const spawn = createMockSpawn({
|
|
203
|
+
stdoutChunks: ['resumed'],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const result = await resumeSession({
|
|
207
|
+
threadId: 'thread_123',
|
|
208
|
+
prompt: 'Follow up task',
|
|
209
|
+
spawn,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(spawn).toHaveBeenCalledWith(
|
|
213
|
+
'codex',
|
|
214
|
+
['exec', 'resume', 'thread_123', 'Follow up task'],
|
|
215
|
+
{}
|
|
216
|
+
);
|
|
217
|
+
expect(result.stdout).toBe('resumed');
|
|
218
|
+
expect(result.exitCode).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
const DEP_DIRS = ['node_modules', 'vendor', '.venv'];
|
|
4
|
+
|
|
5
|
+
function pathExists(fs, targetPath) {
|
|
6
|
+
return fs.existsSync(targetPath);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readExistingLinkTarget(fs, linkPath) {
|
|
10
|
+
try {
|
|
11
|
+
return path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function symlinkDeps({ worktreePath, mainRepoPath, fs = require('fs') }) {
|
|
18
|
+
const linked = [];
|
|
19
|
+
const skipped = [];
|
|
20
|
+
const symlinkType = process.platform === 'win32' ? 'junction' : 'dir';
|
|
21
|
+
|
|
22
|
+
for (const depDir of DEP_DIRS) {
|
|
23
|
+
const sourcePath = path.join(mainRepoPath, depDir);
|
|
24
|
+
const targetPath = path.join(worktreePath, depDir);
|
|
25
|
+
|
|
26
|
+
if (!pathExists(fs, sourcePath)) {
|
|
27
|
+
skipped.push(depDir);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let targetStats = null;
|
|
32
|
+
try {
|
|
33
|
+
targetStats = fs.lstatSync(targetPath);
|
|
34
|
+
} catch {
|
|
35
|
+
targetStats = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (targetStats) {
|
|
39
|
+
if (targetStats.isSymbolicLink()) {
|
|
40
|
+
if (readExistingLinkTarget(fs, targetPath) === sourcePath) {
|
|
41
|
+
skipped.push(depDir);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
46
|
+
} else {
|
|
47
|
+
skipped.push(depDir);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fs.symlinkSync(sourcePath, targetPath, symlinkType);
|
|
53
|
+
linked.push(depDir);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { linked, skipped };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
symlinkDeps,
|
|
61
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const { symlinkDeps } = require('./dep-linker.js');
|
|
8
|
+
|
|
9
|
+
const tempPaths = [];
|
|
10
|
+
|
|
11
|
+
function makeTempDir() {
|
|
12
|
+
const dirPath = path.join(os.tmpdir(), `dep-linker-${crypto.randomUUID()}`);
|
|
13
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
14
|
+
tempPaths.push(dirPath);
|
|
15
|
+
return dirPath;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createRepoPair() {
|
|
19
|
+
const rootPath = makeTempDir();
|
|
20
|
+
const mainRepoPath = path.join(rootPath, 'main');
|
|
21
|
+
const worktreePath = path.join(rootPath, 'worktree');
|
|
22
|
+
|
|
23
|
+
fs.mkdirSync(mainRepoPath, { recursive: true });
|
|
24
|
+
fs.mkdirSync(worktreePath, { recursive: true });
|
|
25
|
+
|
|
26
|
+
return { mainRepoPath, worktreePath };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeDir(targetPath) {
|
|
30
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readLinkTarget(linkPath) {
|
|
34
|
+
return path.resolve(path.dirname(linkPath), fs.readlinkSync(linkPath));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
while (tempPaths.length > 0) {
|
|
39
|
+
fs.rmSync(tempPaths.pop(), { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('dep-linker', () => {
|
|
44
|
+
it('symlinks node_modules when exists in main', () => {
|
|
45
|
+
const { mainRepoPath, worktreePath } = createRepoPair();
|
|
46
|
+
const sourcePath = path.join(mainRepoPath, 'node_modules');
|
|
47
|
+
const linkPath = path.join(worktreePath, 'node_modules');
|
|
48
|
+
|
|
49
|
+
makeDir(sourcePath);
|
|
50
|
+
|
|
51
|
+
const result = symlinkDeps({ worktreePath, mainRepoPath, fs });
|
|
52
|
+
|
|
53
|
+
expect(result.linked).toEqual(['node_modules']);
|
|
54
|
+
expect(result.skipped).toEqual(['vendor', '.venv']);
|
|
55
|
+
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
56
|
+
expect(readLinkTarget(linkPath)).toBe(sourcePath);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('skips node_modules when missing from main', () => {
|
|
60
|
+
const { mainRepoPath, worktreePath } = createRepoPair();
|
|
61
|
+
|
|
62
|
+
const result = symlinkDeps({ worktreePath, mainRepoPath, fs });
|
|
63
|
+
|
|
64
|
+
expect(result.linked).toEqual([]);
|
|
65
|
+
expect(result.skipped).toEqual(['node_modules', 'vendor', '.venv']);
|
|
66
|
+
expect(fs.existsSync(path.join(worktreePath, 'node_modules'))).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('symlinks vendor when exists', () => {
|
|
70
|
+
const { mainRepoPath, worktreePath } = createRepoPair();
|
|
71
|
+
const sourcePath = path.join(mainRepoPath, 'vendor');
|
|
72
|
+
const linkPath = path.join(worktreePath, 'vendor');
|
|
73
|
+
|
|
74
|
+
makeDir(sourcePath);
|
|
75
|
+
|
|
76
|
+
const result = symlinkDeps({ worktreePath, mainRepoPath, fs });
|
|
77
|
+
|
|
78
|
+
expect(result.linked).toEqual(['vendor']);
|
|
79
|
+
expect(result.skipped).toEqual(['node_modules', '.venv']);
|
|
80
|
+
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
81
|
+
expect(readLinkTarget(linkPath)).toBe(sourcePath);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('symlinks .venv when exists', () => {
|
|
85
|
+
const { mainRepoPath, worktreePath } = createRepoPair();
|
|
86
|
+
const sourcePath = path.join(mainRepoPath, '.venv');
|
|
87
|
+
const linkPath = path.join(worktreePath, '.venv');
|
|
88
|
+
|
|
89
|
+
makeDir(sourcePath);
|
|
90
|
+
|
|
91
|
+
const result = symlinkDeps({ worktreePath, mainRepoPath, fs });
|
|
92
|
+
|
|
93
|
+
expect(result.linked).toEqual(['.venv']);
|
|
94
|
+
expect(result.skipped).toEqual(['node_modules', 'vendor']);
|
|
95
|
+
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
96
|
+
expect(readLinkTarget(linkPath)).toBe(sourcePath);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns correct linked and skipped arrays', () => {
|
|
100
|
+
const { mainRepoPath, worktreePath } = createRepoPair();
|
|
101
|
+
|
|
102
|
+
makeDir(path.join(mainRepoPath, 'node_modules'));
|
|
103
|
+
makeDir(path.join(mainRepoPath, 'vendor'));
|
|
104
|
+
|
|
105
|
+
expect(symlinkDeps({ worktreePath, mainRepoPath, fs })).toEqual({
|
|
106
|
+
linked: ['node_modules', 'vendor'],
|
|
107
|
+
skipped: ['.venv'],
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('skips when worktree already has real directory', () => {
|
|
112
|
+
const { mainRepoPath, worktreePath } = createRepoPair();
|
|
113
|
+
const existingPath = path.join(worktreePath, 'node_modules');
|
|
114
|
+
|
|
115
|
+
makeDir(path.join(mainRepoPath, 'node_modules'));
|
|
116
|
+
makeDir(existingPath);
|
|
117
|
+
fs.writeFileSync(path.join(existingPath, 'keep.txt'), 'present');
|
|
118
|
+
|
|
119
|
+
const result = symlinkDeps({ worktreePath, mainRepoPath, fs });
|
|
120
|
+
|
|
121
|
+
expect(result.linked).toEqual([]);
|
|
122
|
+
expect(result.skipped).toEqual(['node_modules', 'vendor', '.venv']);
|
|
123
|
+
expect(fs.lstatSync(existingPath).isDirectory()).toBe(true);
|
|
124
|
+
expect(fs.lstatSync(existingPath).isSymbolicLink()).toBe(false);
|
|
125
|
+
expect(fs.readFileSync(path.join(existingPath, 'keep.txt'), 'utf8')).toBe('present');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('skips when correct symlink already exists', () => {
|
|
129
|
+
const { mainRepoPath, worktreePath } = createRepoPair();
|
|
130
|
+
const sourcePath = path.join(mainRepoPath, 'node_modules');
|
|
131
|
+
const linkPath = path.join(worktreePath, 'node_modules');
|
|
132
|
+
const symlinkType = process.platform === 'win32' ? 'junction' : 'dir';
|
|
133
|
+
|
|
134
|
+
makeDir(sourcePath);
|
|
135
|
+
fs.symlinkSync(sourcePath, linkPath, symlinkType);
|
|
136
|
+
|
|
137
|
+
const result = symlinkDeps({ worktreePath, mainRepoPath, fs });
|
|
138
|
+
|
|
139
|
+
expect(result.linked).toEqual([]);
|
|
140
|
+
expect(result.skipped).toEqual(['node_modules', 'vendor', '.venv']);
|
|
141
|
+
expect(readLinkTarget(linkPath)).toBe(sourcePath);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('handles all three dirs in one call', () => {
|
|
145
|
+
const { mainRepoPath, worktreePath } = createRepoPair();
|
|
146
|
+
|
|
147
|
+
makeDir(path.join(mainRepoPath, 'node_modules'));
|
|
148
|
+
makeDir(path.join(mainRepoPath, 'vendor'));
|
|
149
|
+
makeDir(path.join(mainRepoPath, '.venv'));
|
|
150
|
+
|
|
151
|
+
expect(symlinkDeps({ worktreePath, mainRepoPath, fs })).toEqual({
|
|
152
|
+
linked: ['node_modules', 'vendor', '.venv'],
|
|
153
|
+
skipped: [],
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(fs.lstatSync(path.join(worktreePath, 'node_modules')).isSymbolicLink()).toBe(true);
|
|
157
|
+
expect(fs.lstatSync(path.join(worktreePath, 'vendor')).isSymbolicLink()).toBe(true);
|
|
158
|
+
expect(fs.lstatSync(path.join(worktreePath, '.venv')).isSymbolicLink()).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('works with empty worktree dir', () => {
|
|
162
|
+
const { mainRepoPath, worktreePath } = createRepoPair();
|
|
163
|
+
|
|
164
|
+
makeDir(path.join(mainRepoPath, 'node_modules'));
|
|
165
|
+
|
|
166
|
+
expect(fs.readdirSync(worktreePath)).toEqual([]);
|
|
167
|
+
|
|
168
|
+
const result = symlinkDeps({ worktreePath, mainRepoPath, fs });
|
|
169
|
+
|
|
170
|
+
expect(result.linked).toEqual(['node_modules']);
|
|
171
|
+
expect(result.skipped).toEqual(['vendor', '.venv']);
|
|
172
|
+
expect(fs.lstatSync(path.join(worktreePath, 'node_modules')).isSymbolicLink()).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { packagePrompt } = require('../prompt-packager.js');
|
|
3
|
+
|
|
4
|
+
function formatBulletList(items = []) {
|
|
5
|
+
return items.map((item) => ` - ${item}`).join('\n');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function buildTaskPrompt({ goal, files = [], criteria = [], testCases = [] }) {
|
|
9
|
+
return [
|
|
10
|
+
`Goal: ${goal}`,
|
|
11
|
+
'',
|
|
12
|
+
'Files to work with:',
|
|
13
|
+
formatBulletList(files),
|
|
14
|
+
'',
|
|
15
|
+
'Acceptance criteria:',
|
|
16
|
+
formatBulletList(criteria),
|
|
17
|
+
'',
|
|
18
|
+
'Test cases:',
|
|
19
|
+
formatBulletList(testCases),
|
|
20
|
+
'',
|
|
21
|
+
'Methodology: Write tests first (red → green → refactor). You MUST write the test before implementing any code. Test-first is required.',
|
|
22
|
+
].join('\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildContextPrompt({
|
|
26
|
+
agentPrompt,
|
|
27
|
+
projectDoc,
|
|
28
|
+
planDoc,
|
|
29
|
+
codingStandards,
|
|
30
|
+
files,
|
|
31
|
+
tokenBudget = 100000,
|
|
32
|
+
}) {
|
|
33
|
+
return packagePrompt({
|
|
34
|
+
agentPrompt,
|
|
35
|
+
projectDoc,
|
|
36
|
+
planDoc,
|
|
37
|
+
codingStandards,
|
|
38
|
+
files,
|
|
39
|
+
tokenBudget,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readOptionalFile(fsImpl, filePath) {
|
|
44
|
+
if (!fsImpl.existsSync(filePath)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return fsImpl.readFileSync(filePath, 'utf-8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findPlanPath(projectDir, phase, fsImpl) {
|
|
51
|
+
if (!phase) {
|
|
52
|
+
return path.join(projectDir, 'PLAN.md');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const phasesDir = path.join(projectDir, '.planning', 'phases');
|
|
56
|
+
const exactPath = path.join(phasesDir, `${phase}-PLAN.md`);
|
|
57
|
+
if (fsImpl.existsSync(exactPath)) {
|
|
58
|
+
return exactPath;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!fsImpl.existsSync(phasesDir)) {
|
|
62
|
+
return path.join(projectDir, 'PLAN.md');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const padded = String(phase).padStart(2, '0');
|
|
67
|
+
const match = fsImpl.readdirSync(phasesDir).find(
|
|
68
|
+
(name) =>
|
|
69
|
+
(name.startsWith(`${padded}-`) || name.startsWith(`${phase}-`)) &&
|
|
70
|
+
name.endsWith('-PLAN.md')
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (match) {
|
|
74
|
+
return path.join(phasesDir, match);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
return path.join(projectDir, 'PLAN.md');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return path.join(projectDir, 'PLAN.md');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readRelevantFiles(fsImpl, projectDir, filePaths = []) {
|
|
84
|
+
return filePaths.reduce((acc, filePath) => {
|
|
85
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectDir, filePath);
|
|
86
|
+
if (!fsImpl.existsSync(absolutePath)) {
|
|
87
|
+
return acc;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
acc.push({
|
|
91
|
+
path: filePath,
|
|
92
|
+
content: fsImpl.readFileSync(absolutePath, 'utf-8'),
|
|
93
|
+
});
|
|
94
|
+
return acc;
|
|
95
|
+
}, []);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildFullPrompt({ task, projectDir, phase, fs: fsImpl = require('fs') }) {
|
|
99
|
+
const agentPrompt = buildTaskPrompt(task);
|
|
100
|
+
const projectDoc = readOptionalFile(fsImpl, path.join(projectDir, 'PROJECT.md'));
|
|
101
|
+
const planDoc = readOptionalFile(fsImpl, findPlanPath(projectDir, phase, fsImpl));
|
|
102
|
+
const codingStandards = readOptionalFile(fsImpl, path.join(projectDir, 'CODING-STANDARDS.md'));
|
|
103
|
+
const files = readRelevantFiles(fsImpl, projectDir, task.files);
|
|
104
|
+
|
|
105
|
+
return buildContextPrompt({
|
|
106
|
+
agentPrompt,
|
|
107
|
+
projectDoc,
|
|
108
|
+
planDoc,
|
|
109
|
+
codingStandards,
|
|
110
|
+
files,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
buildTaskPrompt,
|
|
116
|
+
buildContextPrompt,
|
|
117
|
+
buildFullPrompt,
|
|
118
|
+
};
|