@withtruing/companion 0.0.0-stage → 0.13.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/LICENSE +21 -0
- package/README.md +329 -3
- package/dist/api-key-caller.js +388 -0
- package/dist/claude-binary.js +122 -0
- package/dist/claude-cli-caller.js +344 -0
- package/dist/cli-arguments.js +11 -0
- package/dist/cli.js +1486 -0
- package/dist/codex-cli-caller.js +536 -0
- package/dist/config.js +119 -0
- package/dist/design-context.js +54 -0
- package/dist/git-range.js +291 -0
- package/dist/http-client.js +214 -0
- package/dist/import-resolver.js +644 -0
- package/dist/investigation-reader.js +1303 -0
- package/dist/local-scan.js +243 -0
- package/dist/managed-repo.js +408 -0
- package/dist/observed-sources.js +85 -0
- package/dist/reader-result-contract.js +467 -0
- package/dist/sandbox.js +215 -0
- package/dist/schema-entities.js +755 -0
- package/dist/service.js +710 -0
- package/dist/shipment.js +115 -0
- package/dist/strict-schema.js +187 -0
- package/dist/surface-mapper.js +357 -0
- package/dist/text-model.js +121 -0
- package/dist/types.js +11 -0
- package/dist/usage-limit.js +130 -0
- package/package.json +50 -6
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 3a backend (see the local-companion plan's Section 3a) — shells out to an already-authenticated
|
|
3
|
+
* `claude` CLI for full agentic Read/Grep/Glob depth, rather than this package driving its own
|
|
4
|
+
* file-selection pass the way api-key-caller.ts does. One CLI call fulfills a whole task (find
|
|
5
|
+
* the files, read them, do the task) — the model does its own exploring, so every task kind
|
|
6
|
+
* shares the same invocation mechanics below; only the ModelTaskSpec (prompt/schema) differs
|
|
7
|
+
* per kind, extracted from that task's own payload shape by its own thin wrapper at the bottom.
|
|
8
|
+
*
|
|
9
|
+
* Every shape assumed below (the JSON result envelope, the stream-json tool-use event shape,
|
|
10
|
+
* --max-budget-usd's exceeded-budget behavior) was confirmed with real, budgeted `claude` calls
|
|
11
|
+
* during development, not guessed from --help text alone — see the Phase 2 PR description.
|
|
12
|
+
*/
|
|
13
|
+
import { rmSync } from 'node:fs';
|
|
14
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { createInterface } from 'node:readline';
|
|
18
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
19
|
+
import { assertSingleLineArguments } from './cli-arguments.js';
|
|
20
|
+
import { spawnClaude } from './claude-binary.js';
|
|
21
|
+
import { gitErrorMessage } from './sandbox.js';
|
|
22
|
+
import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
|
|
23
|
+
import { ModelRefusalError } from './text-model.js';
|
|
24
|
+
/** Independent of --max-budget-usd — a dollar cap doesn't prevent a network-stall hang. No
|
|
25
|
+
* automatic retry on top of this (see cli.ts): auto-retry would risk double-spending the
|
|
26
|
+
* customer's own subscription/API quota. */
|
|
27
|
+
const WALL_CLOCK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
28
|
+
const INVESTIGATION_CALL_BUDGET_USD = 2;
|
|
29
|
+
const STDOUT_SAMPLE_CHARS = 200;
|
|
30
|
+
const UNPARSEABLE_LINE_SAMPLE_CHARS = 120;
|
|
31
|
+
const EMPTY_MCP_CONFIG = '{"mcpServers":{}}';
|
|
32
|
+
let emptyMcpConfigPathPromise = null;
|
|
33
|
+
let emptyMcpConfigDir = null;
|
|
34
|
+
async function createEmptyMcpConfig() {
|
|
35
|
+
const dir = await mkdtemp(join(tmpdir(), 'product-console-claude-mcp-'));
|
|
36
|
+
const path = join(dir, 'mcp.json');
|
|
37
|
+
try {
|
|
38
|
+
await writeFile(path, EMPTY_MCP_CONFIG, { encoding: 'utf8', mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
await rm(dir, { recursive: true, force: true });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
emptyMcpConfigDir = dir;
|
|
45
|
+
process.once('exit', () => {
|
|
46
|
+
if (emptyMcpConfigDir)
|
|
47
|
+
rmSync(emptyMcpConfigDir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
return path;
|
|
50
|
+
}
|
|
51
|
+
async function getEmptyMcpConfigPath() {
|
|
52
|
+
emptyMcpConfigPathPromise ??= createEmptyMcpConfig();
|
|
53
|
+
try {
|
|
54
|
+
return await emptyMcpConfigPathPromise;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
emptyMcpConfigPathPromise = null;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function sampleForError(value) {
|
|
62
|
+
return gitErrorMessage(JSON.stringify(value));
|
|
63
|
+
}
|
|
64
|
+
function promptWriteFailure(error) {
|
|
65
|
+
const code = error.code ? ` (${error.code})` : '';
|
|
66
|
+
const wrapped = new Error(`Failed to write the prompt to claude CLI stdin${code}: ${gitErrorMessage(error)}`, { cause: error });
|
|
67
|
+
wrapped.code = error.code;
|
|
68
|
+
wrapped.errno = error.errno;
|
|
69
|
+
wrapped.syscall = error.syscall;
|
|
70
|
+
return wrapped;
|
|
71
|
+
}
|
|
72
|
+
/** The model sometimes emits an absolute path matching the sandbox root, sometimes a
|
|
73
|
+
* POSIX-flavoured path as if --add-dir were mounted at "/" — normalize both down to a
|
|
74
|
+
* repo-relative path, matching api-key-caller.ts's own filesRead convention. */
|
|
75
|
+
function normalizeToRepoRelative(filePath, root) {
|
|
76
|
+
const p = filePath.replace(/\\/g, '/');
|
|
77
|
+
const r = root.replace(/\\/g, '/');
|
|
78
|
+
return (p.startsWith(r) ? p.slice(r.length) : p).replace(/^\/+/, '');
|
|
79
|
+
}
|
|
80
|
+
/** The actual invocation — task-kind-agnostic. Repo-scoped calls receive the disposable snapshot
|
|
81
|
+
* sandbox as `repoPath`; connection-less calls grant no tools and need no sandbox.
|
|
82
|
+
* Returns whatever structured_output the model produced plus every file it actually read via
|
|
83
|
+
* the Read tool. Every exported per-kind function below is a thin wrapper over this. */
|
|
84
|
+
async function runViaCli(spec, maxBudgetUsd, repoPath, connectionless = false) {
|
|
85
|
+
if (spec.stableDocumentBase64) {
|
|
86
|
+
throw new Error("This task includes a PDF PRD, which the cli backend can't read yet. Pair with " +
|
|
87
|
+
'--backend api-key instead, or re-upload the PRD as .md/.txt/.docx.');
|
|
88
|
+
}
|
|
89
|
+
const sandboxPath = connectionless ? tmpdir() : repoPath;
|
|
90
|
+
let systemPromptDir = null;
|
|
91
|
+
try {
|
|
92
|
+
systemPromptDir = await mkdtemp(join(tmpdir(), 'product-console-claude-system-'));
|
|
93
|
+
const systemPromptPath = join(systemPromptDir, 'prompt.txt');
|
|
94
|
+
await writeFile(systemPromptPath, spec.system, { encoding: 'utf8', mode: 0o600 });
|
|
95
|
+
const emptyMcpConfigPath = await getEmptyMcpConfigPath();
|
|
96
|
+
const args = [
|
|
97
|
+
'-p',
|
|
98
|
+
'--system-prompt-file',
|
|
99
|
+
systemPromptPath,
|
|
100
|
+
'--strict-mcp-config',
|
|
101
|
+
'--mcp-config',
|
|
102
|
+
emptyMcpConfigPath,
|
|
103
|
+
'--output-format',
|
|
104
|
+
'stream-json',
|
|
105
|
+
'--verbose',
|
|
106
|
+
...(spec.schema ? ['--json-schema', JSON.stringify(spec.schema)] : []),
|
|
107
|
+
'--restricted',
|
|
108
|
+
...(connectionless ? ['--tools', ''] : ['--tools', 'Read,Grep,Glob', '--add-dir', sandboxPath]),
|
|
109
|
+
'--model',
|
|
110
|
+
spec.model,
|
|
111
|
+
'--effort',
|
|
112
|
+
spec.effort,
|
|
113
|
+
'--max-budget-usd',
|
|
114
|
+
String(maxBudgetUsd),
|
|
115
|
+
'--no-session-persistence',
|
|
116
|
+
// Neither prompt is an argv value: on Windows, `claude` resolves to a .cmd shim, and
|
|
117
|
+
// cmd.exe truncates a multi-line argument at its first newline before the program sees
|
|
118
|
+
// the remainder. The system prompt stays a system prompt via the CLI's file option;
|
|
119
|
+
// `claude -p` reads the user prompt from stdin when no positional prompt is given.
|
|
120
|
+
];
|
|
121
|
+
assertSingleLineArguments('claude', args);
|
|
122
|
+
const filesRead = new Set();
|
|
123
|
+
let resultEnvelope = null;
|
|
124
|
+
let lastStopReason = null;
|
|
125
|
+
let stderrTail = '';
|
|
126
|
+
let timedOut = false;
|
|
127
|
+
let stdinError = null;
|
|
128
|
+
let stdoutBytes = 0;
|
|
129
|
+
let stdoutLines = 0;
|
|
130
|
+
let parsedLines = 0;
|
|
131
|
+
let unparseableLines = 0;
|
|
132
|
+
let stdoutFirst = '';
|
|
133
|
+
let stdoutLast = '';
|
|
134
|
+
let firstUnparseableLineLength = null;
|
|
135
|
+
let firstUnparseableLineFirst = '';
|
|
136
|
+
let firstUnparseableLineLast = '';
|
|
137
|
+
const typeCounts = new Map();
|
|
138
|
+
const child = spawnClaude(args, { cwd: sandboxPath });
|
|
139
|
+
// stdout/stderr are only null when stdio is explicitly configured away from 'pipe' — the
|
|
140
|
+
// default here, so both are always real streams in practice; the ! just satisfies the type.
|
|
141
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
142
|
+
const recordStdout = (text) => {
|
|
143
|
+
if (!text)
|
|
144
|
+
return;
|
|
145
|
+
if (stdoutFirst.length < STDOUT_SAMPLE_CHARS) {
|
|
146
|
+
stdoutFirst += text.slice(0, STDOUT_SAMPLE_CHARS - stdoutFirst.length);
|
|
147
|
+
}
|
|
148
|
+
stdoutLast = (stdoutLast + text).slice(-STDOUT_SAMPLE_CHARS);
|
|
149
|
+
};
|
|
150
|
+
child.stdout.on('data', (chunk) => {
|
|
151
|
+
stdoutBytes += chunk.length;
|
|
152
|
+
recordStdout(stdoutDecoder.write(chunk));
|
|
153
|
+
});
|
|
154
|
+
child.stdout.on('end', () => recordStdout(stdoutDecoder.end()));
|
|
155
|
+
child.stdin.on('error', (error) => {
|
|
156
|
+
stdinError ??= error;
|
|
157
|
+
child.kill();
|
|
158
|
+
});
|
|
159
|
+
child.stderr.on('data', (chunk) => {
|
|
160
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-2000);
|
|
161
|
+
});
|
|
162
|
+
const rl = createInterface({ input: child.stdout });
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
timedOut = true;
|
|
165
|
+
child.kill();
|
|
166
|
+
}, WALL_CLOCK_TIMEOUT_MS);
|
|
167
|
+
rl.on('line', (line) => {
|
|
168
|
+
stdoutLines++;
|
|
169
|
+
let event;
|
|
170
|
+
try {
|
|
171
|
+
event = JSON.parse(line);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
unparseableLines++;
|
|
175
|
+
if (firstUnparseableLineLength === null) {
|
|
176
|
+
firstUnparseableLineLength = line.length;
|
|
177
|
+
firstUnparseableLineFirst = line.slice(0, UNPARSEABLE_LINE_SAMPLE_CHARS);
|
|
178
|
+
firstUnparseableLineLast = line.slice(-UNPARSEABLE_LINE_SAMPLE_CHARS);
|
|
179
|
+
}
|
|
180
|
+
return; // one bad stream line must not crash or stop parsing the rest of the run
|
|
181
|
+
}
|
|
182
|
+
parsedLines++;
|
|
183
|
+
const typed = event !== null && typeof event === 'object'
|
|
184
|
+
? event
|
|
185
|
+
: {};
|
|
186
|
+
const eventType = typeof typed.type === 'string' ? typed.type : '(missing)';
|
|
187
|
+
typeCounts.set(eventType, (typeCounts.get(eventType) ?? 0) + 1);
|
|
188
|
+
if (eventType === 'result') {
|
|
189
|
+
resultEnvelope = event;
|
|
190
|
+
}
|
|
191
|
+
else if (eventType === 'assistant') {
|
|
192
|
+
const stopReason = event.message?.stop_reason;
|
|
193
|
+
if (typeof stopReason === 'string')
|
|
194
|
+
lastStopReason = stopReason;
|
|
195
|
+
const blocks = event.message?.content ?? [];
|
|
196
|
+
for (const b of blocks) {
|
|
197
|
+
if (b.type === 'tool_use' && b.name === 'Read' && b.input?.file_path) {
|
|
198
|
+
filesRead.add(normalizeToRepoRelative(b.input.file_path, sandboxPath));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
const exitPromise = new Promise((resolvePromise, reject) => {
|
|
204
|
+
child.on('error', reject); // e.g. ENOENT — claude isn't installed / not on PATH
|
|
205
|
+
child.on('close', (code) => resolvePromise(code ?? 1));
|
|
206
|
+
});
|
|
207
|
+
try {
|
|
208
|
+
if (spec.stableText)
|
|
209
|
+
child.stdin.write(spec.stableText + '\n\n');
|
|
210
|
+
child.stdin.write(spec.volatileText);
|
|
211
|
+
child.stdin.end();
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
stdinError ??= error;
|
|
215
|
+
child.kill();
|
|
216
|
+
}
|
|
217
|
+
const exitCode = await exitPromise;
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
if (!resultEnvelope && timedOut) {
|
|
220
|
+
throw new Error(`claude CLI took longer than ${WALL_CLOCK_TIMEOUT_MS / 60_000} minutes and was killed.`);
|
|
221
|
+
}
|
|
222
|
+
const capturedStdinError = stdinError;
|
|
223
|
+
if (!resultEnvelope) {
|
|
224
|
+
if (capturedStdinError) {
|
|
225
|
+
throw promptWriteFailure(capturedStdinError);
|
|
226
|
+
}
|
|
227
|
+
const plainTextDiagnosis = parsedLines === 0 && unparseableLines > 0
|
|
228
|
+
? 'The claude CLI produced plain text, which means it did not receive --output-format stream-json. '
|
|
229
|
+
: '';
|
|
230
|
+
const types = [...typeCounts.entries()]
|
|
231
|
+
.map(([type, count]) => `${gitErrorMessage(type) || '(empty)'}=${count}`)
|
|
232
|
+
.join(', ') || '(none)';
|
|
233
|
+
const stdoutSample = stdoutBytes === 0
|
|
234
|
+
? '(none)'
|
|
235
|
+
: `first ${STDOUT_SAMPLE_CHARS} chars=${sampleForError(stdoutFirst)}, last ${STDOUT_SAMPLE_CHARS} chars=${sampleForError(stdoutLast)}`;
|
|
236
|
+
const capturedUnparseableLineLength = firstUnparseableLineLength;
|
|
237
|
+
const unparseableSample = capturedUnparseableLineLength === null
|
|
238
|
+
? ''
|
|
239
|
+
: `; first unparseable line: ${capturedUnparseableLineLength} chars, first ${UNPARSEABLE_LINE_SAMPLE_CHARS} chars=${sampleForError(firstUnparseableLineFirst)}, last ${UNPARSEABLE_LINE_SAMPLE_CHARS} chars=${sampleForError(firstUnparseableLineLast)}`;
|
|
240
|
+
throw new Error(`claude CLI exited (code ${exitCode}) without ever producing a result line — ` +
|
|
241
|
+
plainTextDiagnosis +
|
|
242
|
+
`stderr: ${stderrTail ? gitErrorMessage(stderrTail) : '(none)'}; ` +
|
|
243
|
+
`stdout: ${stdoutLines} line${stdoutLines === 1 ? '' : 's'} (${parsedLines} JSON, ${unparseableLines} unparseable), ${stdoutBytes} bytes; ` +
|
|
244
|
+
`types: ${types}; raw stdout: ${stdoutSample}${unparseableSample}`);
|
|
245
|
+
}
|
|
246
|
+
const envelope = resultEnvelope;
|
|
247
|
+
if (envelope.terminal_reason === 'budget_exhausted' || envelope.subtype === 'error_max_budget_usd') {
|
|
248
|
+
throw new Error(`Exceeded the $${maxBudgetUsd.toFixed(2)} budget for this task (spent $${envelope.total_cost_usd.toFixed(4)} before stopping).`);
|
|
249
|
+
}
|
|
250
|
+
if (envelope.is_error) {
|
|
251
|
+
const message = envelope.errors?.length ? envelope.errors.join('; ') : envelope.result;
|
|
252
|
+
throw new Error(message || 'Unknown claude CLI error');
|
|
253
|
+
}
|
|
254
|
+
// A refused turn can still end in a non-error result envelope; its result is empty or partial
|
|
255
|
+
// text, so it must fail here rather than be returned (or JSON-parsed) as an answer.
|
|
256
|
+
if (envelope.stop_reason === 'refusal' || lastStopReason === 'refusal') {
|
|
257
|
+
throw new ModelRefusalError('The model declined this request.');
|
|
258
|
+
}
|
|
259
|
+
if (capturedStdinError) {
|
|
260
|
+
throw promptWriteFailure(capturedStdinError);
|
|
261
|
+
}
|
|
262
|
+
// Only a schema-constrained call has JSON to parse in the first place — design_prompt sends
|
|
263
|
+
// no --json-schema (see its own comment in cli.ts/api-key-caller.ts: it's the one task kind
|
|
264
|
+
// with a free-text markdown answer, never JSON), so envelope.result there is already the
|
|
265
|
+
// raw text answer; JSON.parse-ing it would throw on perfectly valid prose.
|
|
266
|
+
const result = spec.schema ? (envelope.structured_output ?? JSON.parse(envelope.result)) : envelope.result;
|
|
267
|
+
return { result, filesRead: [...filesRead] };
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
if (systemPromptDir)
|
|
271
|
+
await rm(systemPromptDir, { recursive: true, force: true });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** The CLI is only a structured-output model transport for investigations. It gets no tools and
|
|
275
|
+
* no repository directory; all evidence reads happen in investigation-reader.ts first. */
|
|
276
|
+
export async function runStructuredModelCallViaCli(request) {
|
|
277
|
+
const { result } = await runViaCli({
|
|
278
|
+
kind: 'investigation',
|
|
279
|
+
model: request.model,
|
|
280
|
+
effort: request.effort,
|
|
281
|
+
maxTokens: request.maxTokens,
|
|
282
|
+
system: request.system,
|
|
283
|
+
stableText: '',
|
|
284
|
+
volatileText: request.prompt,
|
|
285
|
+
schema: request.schema,
|
|
286
|
+
}, INVESTIGATION_CALL_BUDGET_USD, process.cwd(), true);
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
/** Forward-compatible fallback for server-created graph/spec reasoning tasks. The server sends
|
|
290
|
+
* the complete self-describing spec, so these calls run connectionless with zero tools. */
|
|
291
|
+
export async function runGenericTaskViaCli(task, repoPath) {
|
|
292
|
+
const spec = task.payload?.spec;
|
|
293
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
294
|
+
throw new Error(`Cannot run task kind "${task.kind}": payload.spec is missing or not an object.`);
|
|
295
|
+
}
|
|
296
|
+
return runViaCli(spec, task.maxBudgetUsd, repoPath, true);
|
|
297
|
+
}
|
|
298
|
+
export async function runVerifyModuleTaskViaCli(task, repoPath) {
|
|
299
|
+
const payload = task.payload;
|
|
300
|
+
return runViaCli(payload.spec, task.maxBudgetUsd, repoPath);
|
|
301
|
+
}
|
|
302
|
+
/** moduleIds isn't needed here the way api-key-caller.ts's batch path needs it (to structure its
|
|
303
|
+
* own file-select pass) — spec.volatileText already names every module in the batch. */
|
|
304
|
+
export async function runGenerateSpecBatchTaskViaCli(task, repoPath) {
|
|
305
|
+
const payload = task.payload;
|
|
306
|
+
return runViaCli(payload.spec, task.maxBudgetUsd, repoPath, payload.connectionless);
|
|
307
|
+
}
|
|
308
|
+
/** propose_skeleton and propose_new_capabilities are "shallow whole-repo" tasks (see
|
|
309
|
+
* api-key-caller.ts's own comment on the pair). Repo-scoped calls let the model explore via
|
|
310
|
+
* --add-dir plus Read/Grep/Glob; a connection-less skeleton call has no repo to explore. */
|
|
311
|
+
export async function runProposeSkeletonTaskViaCli(task, repoPath) {
|
|
312
|
+
const payload = task.payload;
|
|
313
|
+
return runViaCli(payload.spec, task.maxBudgetUsd, repoPath, payload.connectionless);
|
|
314
|
+
}
|
|
315
|
+
export async function runProposeNewCapabilitiesTaskViaCli(task, repoPath) {
|
|
316
|
+
const payload = task.payload;
|
|
317
|
+
return runViaCli(payload.spec, task.maxBudgetUsd, repoPath);
|
|
318
|
+
}
|
|
319
|
+
/** design_prompt sends no schema (see runViaCli's own comment above on why that matters for
|
|
320
|
+
* parsing) — otherwise exactly as thin as the other "let the CLI explore itself" wrappers. */
|
|
321
|
+
export async function runDesignPromptTaskViaCli(task, repoPath) {
|
|
322
|
+
const payload = task.payload;
|
|
323
|
+
return runViaCli(payload.spec, task.maxBudgetUsd, repoPath);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* NOT a thin runViaCli wrapper, unlike every kind above — this is the one task kind where even
|
|
327
|
+
* the CLI backend can't let the model explore for itself: `--restricted --tools "Read,Grep,Glob"`
|
|
328
|
+
* gives it no Bash/git tool to run `git show` with, so reading the commit has to happen in this
|
|
329
|
+
* package's own trusted code first (prepareShipmentAnalysis, shipment.ts — shared with
|
|
330
|
+
* api-key-caller.ts's own runAnalyseShipmentTask), with the diff embedded into the prompt before
|
|
331
|
+
* runViaCli is ever called. A trivial/sha_not_local outcome resolves without invoking the CLI at
|
|
332
|
+
* all — same as the api-key backend's own short-circuit, see that function's own comment. */
|
|
333
|
+
export async function runAnalyseShipmentTaskViaCli(task, repoPath) {
|
|
334
|
+
const payload = task.payload;
|
|
335
|
+
const prep = await prepareShipmentAnalysis(repoPath, payload.sha);
|
|
336
|
+
if (prep.outcome === 'sha_not_local') {
|
|
337
|
+
throw new Error(`sha_not_local: commit ${payload.sha} was not found in the local checkout at ${repoPath}, even after a git fetch.`);
|
|
338
|
+
}
|
|
339
|
+
if (prep.outcome === 'trivial') {
|
|
340
|
+
return { result: { touched: [], possibleNewModules: [] }, filesRead: [] };
|
|
341
|
+
}
|
|
342
|
+
const spec = appendDiffToVolatile(payload.spec, prep.diffText, prep.changedPaths);
|
|
343
|
+
return runViaCli(spec, task.maxBudgetUsd, repoPath);
|
|
344
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function assertSingleLineArguments(command, args) {
|
|
2
|
+
for (const [index, argument] of args.entries()) {
|
|
3
|
+
if (!/[\r\n]/.test(argument))
|
|
4
|
+
continue;
|
|
5
|
+
const flag = index > 0 && args[index - 1].startsWith('-')
|
|
6
|
+
? args[index - 1]
|
|
7
|
+
: `argument ${index + 1}`;
|
|
8
|
+
throw new Error(`${command} CLI argument for ${flag} contains a newline; refusing to spawn because ` +
|
|
9
|
+
'cmd.exe silently truncates .cmd arguments at the first newline.');
|
|
10
|
+
}
|
|
11
|
+
}
|