tlc-claude-code 2.5.0 → 2.6.1
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/autofix.md +34 -1
- package/.claude/commands/tlc/build.md +164 -6
- package/.claude/commands/tlc/ci.md +178 -414
- package/.claude/commands/tlc/coverage.md +34 -0
- package/.claude/commands/tlc/deploy.md +19 -6
- package/.claude/commands/tlc/discuss.md +34 -0
- package/.claude/commands/tlc/docs.md +35 -1
- package/.claude/commands/tlc/e2e.md +300 -0
- package/.claude/commands/tlc/edge-cases.md +35 -1
- package/.claude/commands/tlc/init.md +38 -8
- package/.claude/commands/tlc/new-project.md +46 -4
- package/.claude/commands/tlc/plan.md +33 -0
- package/.claude/commands/tlc/quick.md +33 -0
- package/.claude/commands/tlc/release.md +85 -135
- package/.claude/commands/tlc/restore.md +14 -0
- package/.claude/commands/tlc/review.md +76 -1
- package/.claude/commands/tlc/tlc.md +134 -0
- package/.claude/commands/tlc/verify.md +64 -65
- package/.claude/commands/tlc/watchci.md +10 -0
- package/.claude/hooks/tlc-block-tools.sh +13 -0
- package/.claude/hooks/tlc-session-init.sh +29 -0
- package/CODING-STANDARDS.md +35 -10
- package/package.json +1 -1
- package/server/lib/block-tools-hook.js +23 -0
- package/server/lib/e2e/acceptance-parser.js +132 -0
- package/server/lib/e2e/acceptance-parser.test.js +110 -0
- package/server/lib/e2e/framework-detector.js +47 -0
- package/server/lib/e2e/framework-detector.test.js +94 -0
- package/server/lib/e2e/log-assertions.js +107 -0
- package/server/lib/e2e/log-assertions.test.js +68 -0
- package/server/lib/e2e/test-generator.js +159 -0
- package/server/lib/e2e/test-generator.test.js +121 -0
- package/server/lib/e2e/verify-runner.js +191 -0
- package/server/lib/e2e/verify-runner.test.js +167 -0
- package/server/lib/hooks/block-tools-hook.test.js +54 -0
- package/server/lib/orchestration/cli-dispatch.js +16 -1
- package/server/lib/orchestration/cli-dispatch.test.js +94 -8
- package/server/lib/orchestration/completion-checker.js +101 -0
- package/server/lib/orchestration/completion-checker.test.js +177 -0
- package/server/lib/orchestration/result-verifier.js +143 -0
- package/server/lib/orchestration/result-verifier.test.js +291 -0
- package/server/lib/orchestration/session-dispatcher.js +99 -0
- package/server/lib/orchestration/session-dispatcher.test.js +215 -0
- package/server/lib/orchestration/session-status.js +147 -0
- package/server/lib/orchestration/session-status.test.js +130 -0
- package/server/lib/release/agent-runner-updates.js +24 -0
- package/server/lib/release/agent-runner-updates.test.js +22 -0
- package/server/lib/release/changelog-generator.js +142 -0
- package/server/lib/release/changelog-generator.test.js +113 -0
- package/server/lib/release/ci-watcher.js +83 -0
- package/server/lib/release/ci-watcher.test.js +81 -0
- package/server/lib/release/health-checker.js +111 -0
- package/server/lib/release/health-checker.test.js +121 -0
- package/server/lib/release/release-pipeline.js +187 -0
- package/server/lib/release/release-pipeline.test.js +262 -0
- package/server/lib/release/version-bumper.js +183 -0
- package/server/lib/release/version-bumper.test.js +142 -0
- package/server/lib/routing-preamble.integration.test.js +12 -0
- package/server/lib/routing-preamble.js +13 -2
- package/server/lib/routing-preamble.test.js +49 -0
- package/server/lib/scaffolding/ci-detector.js +139 -0
- package/server/lib/scaffolding/ci-detector.test.js +198 -0
- package/server/lib/scaffolding/ci-scaffolder.js +347 -0
- package/server/lib/scaffolding/ci-scaffolder.test.js +157 -0
- package/server/lib/scaffolding/deploy-detector.js +135 -0
- package/server/lib/scaffolding/deploy-detector.test.js +106 -0
- package/server/lib/scaffolding/health-scaffold.js +374 -0
- package/server/lib/scaffolding/health-scaffold.test.js +99 -0
- package/server/lib/scaffolding/logger-scaffold.js +196 -0
- package/server/lib/scaffolding/logger-scaffold.test.js +146 -0
- package/server/lib/scaffolding/migration-detector.js +78 -0
- package/server/lib/scaffolding/migration-detector.test.js +127 -0
- package/server/lib/scaffolding/snapshot-manager.js +142 -0
- package/server/lib/scaffolding/snapshot-manager.test.js +225 -0
- package/server/lib/task-router-config.js +50 -20
- package/server/lib/task-router-config.test.js +29 -15
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseAcceptanceCriteria } = require('./acceptance-parser.js');
|
|
4
|
+
const { detectE2eFramework } = require('./framework-detector.js');
|
|
5
|
+
|
|
6
|
+
function normalizeLogErrors(logErrors) {
|
|
7
|
+
if (Array.isArray(logErrors)) {
|
|
8
|
+
return logErrors
|
|
9
|
+
.map((entry) => String(entry || '').trim())
|
|
10
|
+
.filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (typeof logErrors === 'string') {
|
|
14
|
+
const trimmed = logErrors.trim();
|
|
15
|
+
return trimmed ? [trimmed] : [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildExecFailureLogs(execResult) {
|
|
22
|
+
const explicitLogs = normalizeLogErrors(execResult && execResult.logErrors);
|
|
23
|
+
if (explicitLogs.length > 0) {
|
|
24
|
+
return explicitLogs;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const output = [execResult && execResult.stderr, execResult && execResult.stdout]
|
|
28
|
+
.filter((value) => typeof value === 'string')
|
|
29
|
+
.join('\n')
|
|
30
|
+
.trim();
|
|
31
|
+
|
|
32
|
+
if (output) {
|
|
33
|
+
return [output];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const exitCode = execResult && Number.isInteger(execResult.code) ? execResult.code : 1;
|
|
37
|
+
return [`E2E command exited with code ${exitCode}`];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeCriterionResult(entry, fallback) {
|
|
41
|
+
const status = entry && typeof entry.status === 'string'
|
|
42
|
+
? entry.status.trim().toLowerCase()
|
|
43
|
+
: fallback.status;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
criterion: fallback.criterion,
|
|
47
|
+
status: status === 'verified' || status === 'manual' || status === 'failed'
|
|
48
|
+
? status
|
|
49
|
+
: fallback.status,
|
|
50
|
+
screenshot: entry && typeof entry.screenshot === 'string' && entry.screenshot.trim()
|
|
51
|
+
? entry.screenshot
|
|
52
|
+
: fallback.screenshot,
|
|
53
|
+
logErrors: entry && Object.prototype.hasOwnProperty.call(entry, 'logErrors')
|
|
54
|
+
? normalizeLogErrors(entry.logErrors)
|
|
55
|
+
: fallback.logErrors,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildSummary(results) {
|
|
60
|
+
return results.reduce((summary, item) => {
|
|
61
|
+
if (item.status === 'verified') summary.verified += 1;
|
|
62
|
+
if (item.status === 'manual') summary.manual += 1;
|
|
63
|
+
if (item.status === 'failed') summary.failed += 1;
|
|
64
|
+
return summary;
|
|
65
|
+
}, { verified: 0, manual: 0, failed: 0 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function buildCommand(framework) {
|
|
69
|
+
if (framework === 'playwright') {
|
|
70
|
+
return {
|
|
71
|
+
command: 'npx',
|
|
72
|
+
args: ['playwright', 'test', '--reporter=list'],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (framework === 'supertest') {
|
|
77
|
+
return {
|
|
78
|
+
command: 'npx',
|
|
79
|
+
args: ['vitest', 'run'],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function runVerification(options) {
|
|
87
|
+
const {
|
|
88
|
+
planPath,
|
|
89
|
+
projectDir,
|
|
90
|
+
exec,
|
|
91
|
+
fs,
|
|
92
|
+
} = options || {};
|
|
93
|
+
|
|
94
|
+
if (typeof planPath !== 'string' || !planPath.trim()) {
|
|
95
|
+
throw new TypeError('runVerification requires planPath');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (typeof projectDir !== 'string' || !projectDir.trim()) {
|
|
99
|
+
throw new TypeError('runVerification requires projectDir');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (typeof exec !== 'function') {
|
|
103
|
+
throw new TypeError('runVerification requires exec');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!fs || typeof fs.readFileSync !== 'function' || typeof fs.existsSync !== 'function') {
|
|
107
|
+
throw new TypeError('runVerification requires fs with readFileSync and existsSync');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const planContent = fs.readFileSync(planPath, 'utf8');
|
|
111
|
+
const criteria = parseAcceptanceCriteria({ planContent });
|
|
112
|
+
const manualResults = [];
|
|
113
|
+
const actionableCriteria = [];
|
|
114
|
+
|
|
115
|
+
for (const item of criteria) {
|
|
116
|
+
if (item.type === 'manual') {
|
|
117
|
+
manualResults.push({
|
|
118
|
+
criterion: item.criterion,
|
|
119
|
+
status: 'manual',
|
|
120
|
+
screenshot: null,
|
|
121
|
+
logErrors: [],
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
actionableCriteria.push(item);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (actionableCriteria.length === 0) {
|
|
130
|
+
const results = manualResults;
|
|
131
|
+
return {
|
|
132
|
+
...buildSummary(results),
|
|
133
|
+
results,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { framework } = detectE2eFramework({ projectDir, fs });
|
|
138
|
+
const command = buildCommand(framework);
|
|
139
|
+
|
|
140
|
+
if (!command) {
|
|
141
|
+
const results = actionableCriteria.map(({ criterion }) => ({
|
|
142
|
+
criterion,
|
|
143
|
+
status: 'failed',
|
|
144
|
+
screenshot: null,
|
|
145
|
+
logErrors: ['No supported E2E framework detected'],
|
|
146
|
+
})).concat(manualResults);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
...buildSummary(results),
|
|
150
|
+
results,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const execResult = await exec({
|
|
155
|
+
...command,
|
|
156
|
+
cwd: projectDir,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const passed = execResult && execResult.code === 0;
|
|
160
|
+
const fallback = actionableCriteria.map(({ criterion }) => ({
|
|
161
|
+
criterion,
|
|
162
|
+
status: passed ? 'verified' : 'failed',
|
|
163
|
+
screenshot: execResult && typeof execResult.screenshot === 'string' ? execResult.screenshot : null,
|
|
164
|
+
logErrors: passed ? normalizeLogErrors(execResult && execResult.logErrors) : buildExecFailureLogs(execResult),
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
const explicitResults = new Map();
|
|
168
|
+
for (const entry of Array.isArray(execResult && execResult.results) ? execResult.results : []) {
|
|
169
|
+
if (!entry || typeof entry.criterion !== 'string') {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
explicitResults.set(entry.criterion.trim(), entry);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const actionableResults = fallback.map((item) => normalizeCriterionResult(
|
|
177
|
+
explicitResults.get(item.criterion),
|
|
178
|
+
item
|
|
179
|
+
));
|
|
180
|
+
|
|
181
|
+
const results = actionableResults.concat(manualResults);
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
...buildSummary(results),
|
|
185
|
+
results,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
module.exports = {
|
|
190
|
+
runVerification,
|
|
191
|
+
};
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const { runVerification } = require('./verify-runner.js');
|
|
6
|
+
|
|
7
|
+
function createFs(planContent, extraFiles = {}) {
|
|
8
|
+
return {
|
|
9
|
+
readFileSync(path) {
|
|
10
|
+
if (path === '/tmp/phase-plan.md') {
|
|
11
|
+
return planContent;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (Object.prototype.hasOwnProperty.call(extraFiles, path)) {
|
|
15
|
+
return extraFiles[path];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
throw new Error(`Unexpected read: ${path}`);
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
existsSync(path) {
|
|
22
|
+
return Object.prototype.hasOwnProperty.call(extraFiles, path);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('runVerification', () => {
|
|
28
|
+
it('runs the detected E2E command and marks automatable criteria verified', async () => {
|
|
29
|
+
const fs = createFs(`
|
|
30
|
+
- [ ] User can click the export button
|
|
31
|
+
Data is retained for 30 days.
|
|
32
|
+
`, {
|
|
33
|
+
'/repo/package.json': JSON.stringify({
|
|
34
|
+
devDependencies: {
|
|
35
|
+
supertest: '^7.2.2',
|
|
36
|
+
},
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const exec = vi.fn().mockResolvedValue({
|
|
41
|
+
code: 0,
|
|
42
|
+
results: [
|
|
43
|
+
{
|
|
44
|
+
criterion: 'User can click the export button',
|
|
45
|
+
screenshot: 'artifacts/export.png',
|
|
46
|
+
logErrors: [],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const result = await runVerification({
|
|
52
|
+
planPath: '/tmp/phase-plan.md',
|
|
53
|
+
projectDir: '/repo',
|
|
54
|
+
exec,
|
|
55
|
+
fs,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(exec).toHaveBeenCalledWith({
|
|
59
|
+
command: 'npx',
|
|
60
|
+
args: ['vitest', 'run'],
|
|
61
|
+
cwd: '/repo',
|
|
62
|
+
});
|
|
63
|
+
expect(result).toEqual({
|
|
64
|
+
verified: 1,
|
|
65
|
+
manual: 1,
|
|
66
|
+
failed: 0,
|
|
67
|
+
results: [
|
|
68
|
+
{
|
|
69
|
+
criterion: 'User can click the export button',
|
|
70
|
+
status: 'verified',
|
|
71
|
+
screenshot: 'artifacts/export.png',
|
|
72
|
+
logErrors: [],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
criterion: 'Data is retained for 30 days.',
|
|
76
|
+
status: 'manual',
|
|
77
|
+
screenshot: null,
|
|
78
|
+
logErrors: [],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('uses playwright when the project has a playwright config', async () => {
|
|
85
|
+
const fs = createFs('- [ ] User can click the sign in button', {
|
|
86
|
+
'/repo/playwright.config.ts': 'export default {};',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const exec = vi.fn().mockResolvedValue({ code: 0 });
|
|
90
|
+
|
|
91
|
+
await runVerification({
|
|
92
|
+
planPath: '/tmp/phase-plan.md',
|
|
93
|
+
projectDir: '/repo',
|
|
94
|
+
exec,
|
|
95
|
+
fs,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(exec).toHaveBeenCalledWith({
|
|
99
|
+
command: 'npx',
|
|
100
|
+
args: ['playwright', 'test', '--reporter=list'],
|
|
101
|
+
cwd: '/repo',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('fails automatable criteria when no supported E2E framework is detected', async () => {
|
|
106
|
+
const fs = createFs('- [ ] User can click the sign in button');
|
|
107
|
+
const exec = vi.fn();
|
|
108
|
+
|
|
109
|
+
const result = await runVerification({
|
|
110
|
+
planPath: '/tmp/phase-plan.md',
|
|
111
|
+
projectDir: '/repo',
|
|
112
|
+
exec,
|
|
113
|
+
fs,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(exec).not.toHaveBeenCalled();
|
|
117
|
+
expect(result).toEqual({
|
|
118
|
+
verified: 0,
|
|
119
|
+
manual: 0,
|
|
120
|
+
failed: 1,
|
|
121
|
+
results: [
|
|
122
|
+
{
|
|
123
|
+
criterion: 'User can click the sign in button',
|
|
124
|
+
status: 'failed',
|
|
125
|
+
screenshot: null,
|
|
126
|
+
logErrors: ['No supported E2E framework detected'],
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('propagates failed executions into each automatable criterion', async () => {
|
|
133
|
+
const fs = createFs(`
|
|
134
|
+
Given the user is on the login page
|
|
135
|
+
When they click the sign in button
|
|
136
|
+
Then the dashboard page should load
|
|
137
|
+
`, {
|
|
138
|
+
'/repo/playwright.config.ts': 'export default {};',
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const exec = vi.fn().mockResolvedValue({
|
|
142
|
+
code: 1,
|
|
143
|
+
stderr: 'Timeout while waiting for dashboard',
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = await runVerification({
|
|
147
|
+
planPath: '/tmp/phase-plan.md',
|
|
148
|
+
projectDir: '/repo',
|
|
149
|
+
exec,
|
|
150
|
+
fs,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result).toEqual({
|
|
154
|
+
verified: 0,
|
|
155
|
+
manual: 0,
|
|
156
|
+
failed: 1,
|
|
157
|
+
results: [
|
|
158
|
+
{
|
|
159
|
+
criterion: 'the dashboard page should load',
|
|
160
|
+
status: 'failed',
|
|
161
|
+
screenshot: null,
|
|
162
|
+
logErrors: ['Timeout while waiting for dashboard'],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { BUILD_ROUTING_FLAG, shouldBlockAgent } from '../block-tools-hook.js';
|
|
6
|
+
|
|
7
|
+
const tempDirs = [];
|
|
8
|
+
|
|
9
|
+
function createProjectRoot() {
|
|
10
|
+
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'block-tools-hook-'));
|
|
11
|
+
tempDirs.push(projectRoot);
|
|
12
|
+
return projectRoot;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeFlag(projectRoot, value) {
|
|
16
|
+
const flagPath = path.join(projectRoot, BUILD_ROUTING_FLAG);
|
|
17
|
+
fs.mkdirSync(path.dirname(flagPath), { recursive: true });
|
|
18
|
+
fs.writeFileSync(flagPath, value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
for (const dir of tempDirs.splice(0)) {
|
|
23
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('shouldBlockAgent', () => {
|
|
28
|
+
it('returns provider when file has codex', () => {
|
|
29
|
+
const projectRoot = createProjectRoot();
|
|
30
|
+
writeFlag(projectRoot, 'codex');
|
|
31
|
+
|
|
32
|
+
expect(shouldBlockAgent(projectRoot)).toBe('codex');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns null when claude', () => {
|
|
36
|
+
const projectRoot = createProjectRoot();
|
|
37
|
+
writeFlag(projectRoot, 'claude');
|
|
38
|
+
|
|
39
|
+
expect(shouldBlockAgent(projectRoot)).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns null when missing', () => {
|
|
43
|
+
const projectRoot = createProjectRoot();
|
|
44
|
+
|
|
45
|
+
expect(shouldBlockAgent(projectRoot)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns null when empty', () => {
|
|
49
|
+
const projectRoot = createProjectRoot();
|
|
50
|
+
writeFlag(projectRoot, '');
|
|
51
|
+
|
|
52
|
+
expect(shouldBlockAgent(projectRoot)).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -49,10 +49,14 @@ function buildCommand(provider, prompt, worktreePath, flags) {
|
|
|
49
49
|
let args;
|
|
50
50
|
|
|
51
51
|
if (normalizedProvider === 'codex') {
|
|
52
|
-
args = ['exec', '--json', '--full-auto'];
|
|
52
|
+
args = ['exec', '--json', '--full-auto', '--ephemeral'];
|
|
53
53
|
if (worktreePath) {
|
|
54
54
|
args.push('-C', worktreePath);
|
|
55
55
|
}
|
|
56
|
+
// Short prompts go inline; long prompts use stdin
|
|
57
|
+
if (prompt && prompt.length < 1000) {
|
|
58
|
+
usesStdin = false;
|
|
59
|
+
}
|
|
56
60
|
} else if (normalizedProvider === 'gemini') {
|
|
57
61
|
usesStdin = false;
|
|
58
62
|
args = [...baseFlags, '-p', prompt];
|
|
@@ -84,6 +88,10 @@ function buildCommand(provider, prompt, worktreePath, flags) {
|
|
|
84
88
|
}
|
|
85
89
|
|
|
86
90
|
args.push(...baseFlags);
|
|
91
|
+
// For codex with short prompts, append prompt as inline argument
|
|
92
|
+
if (normalizedProvider === 'codex' && !usesStdin && prompt) {
|
|
93
|
+
args.push(prompt);
|
|
94
|
+
}
|
|
87
95
|
return { command: normalizedProvider, args, spawnOptions, usesStdin };
|
|
88
96
|
}
|
|
89
97
|
|
|
@@ -112,6 +120,13 @@ async function dispatch({
|
|
|
112
120
|
spawn = require('child_process').spawn,
|
|
113
121
|
binaryOverride,
|
|
114
122
|
}) {
|
|
123
|
+
const PROMPT_SIZE_THRESHOLD = 16000;
|
|
124
|
+
if (prompt && prompt.length > PROMPT_SIZE_THRESHOLD) {
|
|
125
|
+
console.warn(
|
|
126
|
+
`[cli-dispatch] Prompt length (${prompt.length} chars) exceeds ${PROMPT_SIZE_THRESHOLD} char threshold (~${Math.round(prompt.length / 4)} tokens). Consider breaking the prompt into smaller tasks.`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
115
130
|
const start = Date.now();
|
|
116
131
|
const built = buildCommand(provider, prompt, worktreePath, flags);
|
|
117
132
|
// Allow standalone to preserve custom/absolute binary paths
|
|
@@ -30,7 +30,7 @@ function createMockProcess({ stdout = '', stderr = '', exitCode = 0, delay = 0 }
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
describe('cli-dispatch', () => {
|
|
33
|
-
it('builds codex args with -C,
|
|
33
|
+
it('builds codex args with -C, handles prompt, and extracts threadId', async () => {
|
|
34
34
|
const stdout = [
|
|
35
35
|
'{"type":"thread.started","thread_id":"thread-123"}',
|
|
36
36
|
'{"type":"agent_message","message":{"items":[{"type":"text","text":"done"}]}}',
|
|
@@ -46,12 +46,18 @@ describe('cli-dispatch', () => {
|
|
|
46
46
|
spawn,
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
);
|
|
54
|
-
expect(
|
|
49
|
+
const args = spawn.mock.calls[0][1];
|
|
50
|
+
expect(args).toContain('exec');
|
|
51
|
+
expect(args).toContain('--json');
|
|
52
|
+
expect(args).toContain('--full-auto');
|
|
53
|
+
expect(args).toContain('--ephemeral');
|
|
54
|
+
expect(args).toContain('-C');
|
|
55
|
+
expect(args).toContain('/tmp/worktree');
|
|
56
|
+
expect(args).toContain('--model');
|
|
57
|
+
expect(args).toContain('gpt-5.4');
|
|
58
|
+
// Short prompt (<1000 chars) goes inline
|
|
59
|
+
expect(args).toContain('Review this change');
|
|
60
|
+
expect(proc.stdin.write).not.toHaveBeenCalled();
|
|
55
61
|
expect(proc.stdin.end).toHaveBeenCalled();
|
|
56
62
|
expect(result.threadId).toBe('thread-123');
|
|
57
63
|
expect(result.exitCode).toBe(0);
|
|
@@ -221,7 +227,7 @@ describe('cli-dispatch', () => {
|
|
|
221
227
|
|
|
222
228
|
expect(spawn).toHaveBeenCalledWith(
|
|
223
229
|
'codex',
|
|
224
|
-
['
|
|
230
|
+
expect.arrayContaining(['-C', '/tmp/codex-worktree']),
|
|
225
231
|
{}
|
|
226
232
|
);
|
|
227
233
|
});
|
|
@@ -239,4 +245,84 @@ describe('cli-dispatch', () => {
|
|
|
239
245
|
expect(result.threadId).toBeNull();
|
|
240
246
|
expect(result.exitCode).toBe(0);
|
|
241
247
|
});
|
|
248
|
+
|
|
249
|
+
it('buildCommand for codex includes --ephemeral flag', async () => {
|
|
250
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
251
|
+
const spawn = vi.fn(() => proc);
|
|
252
|
+
|
|
253
|
+
await dispatch({
|
|
254
|
+
provider: 'codex',
|
|
255
|
+
prompt: 'Do something',
|
|
256
|
+
spawn,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const args = spawn.mock.calls[0][1];
|
|
260
|
+
expect(args).toContain('--ephemeral');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('buildCommand for codex includes -o flag when outputFile provided', async () => {
|
|
264
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
265
|
+
const spawn = vi.fn(() => proc);
|
|
266
|
+
|
|
267
|
+
await dispatch({
|
|
268
|
+
provider: 'codex',
|
|
269
|
+
prompt: 'Do something',
|
|
270
|
+
flags: ['-o', '/tmp/output.json'],
|
|
271
|
+
spawn,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const args = spawn.mock.calls[0][1];
|
|
275
|
+
expect(args).toContain('-o');
|
|
276
|
+
expect(args).toContain('/tmp/output.json');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('buildCommand for codex uses inline argument for short prompts', async () => {
|
|
280
|
+
const shortPrompt = 'Fix the bug';
|
|
281
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
282
|
+
const spawn = vi.fn(() => proc);
|
|
283
|
+
|
|
284
|
+
const result = await dispatch({
|
|
285
|
+
provider: 'codex',
|
|
286
|
+
prompt: shortPrompt,
|
|
287
|
+
spawn,
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const args = spawn.mock.calls[0][1];
|
|
291
|
+
expect(args).toContain(shortPrompt);
|
|
292
|
+
expect(proc.stdin.write).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('buildCommand for codex uses stdin for long prompts', async () => {
|
|
296
|
+
const longPrompt = 'x'.repeat(1000);
|
|
297
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
298
|
+
const spawn = vi.fn(() => proc);
|
|
299
|
+
|
|
300
|
+
await dispatch({
|
|
301
|
+
provider: 'codex',
|
|
302
|
+
prompt: longPrompt,
|
|
303
|
+
spawn,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const args = spawn.mock.calls[0][1];
|
|
307
|
+
expect(args).not.toContain(longPrompt);
|
|
308
|
+
expect(proc.stdin.write).toHaveBeenCalledWith(longPrompt);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('dispatch warns when prompt exceeds size threshold', async () => {
|
|
312
|
+
const hugePrompt = 'x'.repeat(16001);
|
|
313
|
+
const proc = createMockProcess({ stdout: 'ok' });
|
|
314
|
+
const spawn = vi.fn(() => proc);
|
|
315
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
316
|
+
|
|
317
|
+
await dispatch({
|
|
318
|
+
provider: 'codex',
|
|
319
|
+
prompt: hugePrompt,
|
|
320
|
+
spawn,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
324
|
+
expect.stringContaining('exceeds')
|
|
325
|
+
);
|
|
326
|
+
warnSpy.mockRestore();
|
|
327
|
+
});
|
|
242
328
|
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const fs = require('fs/promises');
|
|
2
|
+
|
|
3
|
+
async function readActiveSessions(activeSessionsPath) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = await fs.readFile(activeSessionsPath, 'utf8');
|
|
6
|
+
const parsed = JSON.parse(raw);
|
|
7
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
8
|
+
} catch (error) {
|
|
9
|
+
if (error && error.code === 'ENOENT') {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function writeActiveSessions(activeSessionsPath, sessions) {
|
|
17
|
+
await fs.mkdir(require('path').dirname(activeSessionsPath), { recursive: true });
|
|
18
|
+
await fs.writeFile(activeSessionsPath, JSON.stringify(sessions, null, 2));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function checkCompletions({
|
|
22
|
+
activeSessionsPath,
|
|
23
|
+
orchestratorUrl,
|
|
24
|
+
fetch = globalThis.fetch,
|
|
25
|
+
}) {
|
|
26
|
+
const activeSessions = await readActiveSessions(activeSessionsPath);
|
|
27
|
+
if (activeSessions.length === 0) {
|
|
28
|
+
return {
|
|
29
|
+
completions: [],
|
|
30
|
+
failures: [],
|
|
31
|
+
stillRunning: 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const completions = [];
|
|
36
|
+
const failures = [];
|
|
37
|
+
const remainingSessions = [];
|
|
38
|
+
|
|
39
|
+
let orchestratorReachable = true;
|
|
40
|
+
|
|
41
|
+
for (const session of activeSessions) {
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(
|
|
44
|
+
`${String(orchestratorUrl).replace(/\/+$/, '')}/sessions/${session.sessionId}`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
// Server returned error for this session — keep it as running, don't abort
|
|
49
|
+
remainingSessions.push(session);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const payload = await response.json();
|
|
54
|
+
|
|
55
|
+
if (payload.status === 'completed') {
|
|
56
|
+
completions.push(session);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (payload.status === 'failed') {
|
|
61
|
+
failures.push({
|
|
62
|
+
...session,
|
|
63
|
+
...(payload.reason ? { reason: payload.reason } : {}),
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
remainingSessions.push(session);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!orchestratorReachable) {
|
|
71
|
+
// Already know orchestrator is down — keep remaining sessions as-is
|
|
72
|
+
remainingSessions.push(session);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// First network error — check if orchestrator is entirely unreachable
|
|
76
|
+
orchestratorReachable = false;
|
|
77
|
+
remainingSessions.push(session);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!orchestratorReachable && completions.length === 0 && failures.length === 0) {
|
|
82
|
+
return {
|
|
83
|
+
completions: [],
|
|
84
|
+
failures: [],
|
|
85
|
+
stillRunning: -1,
|
|
86
|
+
error: 'orchestrator unreachable',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await writeActiveSessions(activeSessionsPath, remainingSessions);
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
completions,
|
|
94
|
+
failures,
|
|
95
|
+
stillRunning: remainingSessions.length,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = {
|
|
100
|
+
checkCompletions,
|
|
101
|
+
};
|