@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,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The 3b backend (see the local-companion plan's Section 3) — no CLI dependency, calls the
|
|
3
|
+
* model directly with a locally-configured API key. Structurally mirrors server/drift.ts's
|
|
4
|
+
* verifyModuleAgainstCode almost exactly (same two-pass shape: pick candidate files, then judge
|
|
5
|
+
* against their contents) — the two things that actually change are which machine runs it and
|
|
6
|
+
* that file reads go through node:fs against the local checkout instead of the GitHub API, so
|
|
7
|
+
* nothing here truncates a file's contents the way MAX_FILE_CHARS does server-side.
|
|
8
|
+
*
|
|
9
|
+
* fileSelectSchema below is a deliberate duplicate of server/drift.ts's own (not imported —
|
|
10
|
+
* this package is meant to be independently publishable, see types.ts's own comment). Keep the
|
|
11
|
+
* two in sync by hand if either changes.
|
|
12
|
+
*/
|
|
13
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { buildLocalDesignContext } from './design-context.js';
|
|
17
|
+
import { buildLocalRepoContext, listRepoFiles } from './local-scan.js';
|
|
18
|
+
import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
|
|
19
|
+
import { replyText, streamWithFallback } from './text-model.js';
|
|
20
|
+
const MAX_CANDIDATE_FILES = 8;
|
|
21
|
+
/** A skip threshold, not a truncation — a file over this is left OUT of what's read entirely
|
|
22
|
+
* (with a note in the log), rather than read and cut off partway through. The whole point of
|
|
23
|
+
* this backend is that what IS read is never truncated; this only guards against an
|
|
24
|
+
* accidentally-selected generated/binary/data file blowing up the prompt. */
|
|
25
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
26
|
+
/** Structured model-only call used by the investigation reader. The reader, not this call and
|
|
27
|
+
* not the model, owns every repository read and evidence id. */
|
|
28
|
+
export async function runStructuredModelCall(client, request) {
|
|
29
|
+
const stream = streamWithFallback(client, {
|
|
30
|
+
model: request.model,
|
|
31
|
+
max_tokens: request.maxTokens,
|
|
32
|
+
thinking: { type: 'adaptive' },
|
|
33
|
+
output_config: {
|
|
34
|
+
effort: request.effort,
|
|
35
|
+
format: { type: 'json_schema', schema: request.schema },
|
|
36
|
+
},
|
|
37
|
+
system: request.system,
|
|
38
|
+
messages: [{ role: 'user', content: request.prompt }],
|
|
39
|
+
});
|
|
40
|
+
let message;
|
|
41
|
+
try {
|
|
42
|
+
message = await stream.finalMessage();
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof Anthropic.RateLimitError) {
|
|
46
|
+
throw new Error('budget_exhausted: Anthropic API rate limit (HTTP 429).', { cause: error });
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
if (message.stop_reason === 'max_tokens')
|
|
51
|
+
throw new Error('budget_exhausted: the structured investigation call reached its output-token limit.');
|
|
52
|
+
const text = replyText(message, 'The model declined the structured investigation call.');
|
|
53
|
+
if (!text)
|
|
54
|
+
throw new Error('The structured investigation call returned no text.');
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
export const fileSelectSchema = {
|
|
58
|
+
type: 'object',
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
required: ['paths'],
|
|
61
|
+
properties: {
|
|
62
|
+
paths: {
|
|
63
|
+
type: 'array',
|
|
64
|
+
items: { type: 'string' },
|
|
65
|
+
description: `Up to ${MAX_CANDIDATE_FILES} repo file paths (verbatim from the tree) most likely to implement this module. Prefer source over tests/config. Empty if nothing in the tree plausibly implements it.`,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
async function readLocalFile(repoPath, relPath) {
|
|
70
|
+
try {
|
|
71
|
+
const buf = await readFile(join(repoPath, relPath));
|
|
72
|
+
if (buf.byteLength > MAX_FILE_BYTES) {
|
|
73
|
+
console.warn(` [skip] ${relPath} is ${Math.round(buf.byteLength / 1024)}KB — over the ${MAX_FILE_BYTES / 1024}KB safety threshold, leaving it out rather than truncating it`);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return buf.toString('utf8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Runs the whole verify_module task locally and returns exactly what
|
|
83
|
+
* POST /api/companion/tasks/:id/result expects — `result` validated server-side against the
|
|
84
|
+
* exact schema the task shipped with (task.payload.spec.schema), `filesRead` for the UI's
|
|
85
|
+
* provenance note. Throws on anything that should surface as a task error (caller reports it
|
|
86
|
+
* via POST .../error). */
|
|
87
|
+
export async function runVerifyModuleTask(callModel, task, repoPath) {
|
|
88
|
+
const payload = task.payload;
|
|
89
|
+
const spec = payload.spec;
|
|
90
|
+
const allFiles = await listRepoFiles(repoPath);
|
|
91
|
+
console.log(` scanning ${allFiles.length} files in ${repoPath}…`);
|
|
92
|
+
// Pass 1: which files implement this module? Reuses the module-spec text the server already
|
|
93
|
+
// wrote into spec.volatileText (buildVerifyModuleTaskSpec) as context — it already contains
|
|
94
|
+
// the full spec, so this doesn't need to re-derive it.
|
|
95
|
+
const selectPrompt = `${spec.volatileText}
|
|
96
|
+
|
|
97
|
+
# The repository file tree (${allFiles.length} files)
|
|
98
|
+
|
|
99
|
+
${allFiles.join('\n')}
|
|
100
|
+
|
|
101
|
+
# Before judging
|
|
102
|
+
|
|
103
|
+
First, list the file paths (verbatim from the tree above, up to ${MAX_CANDIDATE_FILES}) most
|
|
104
|
+
likely to implement this module's behaviour. Prefer application source over tests, config, and
|
|
105
|
+
generated files. Empty list if nothing plausibly implements it.`;
|
|
106
|
+
const selectMsg = await callModel({
|
|
107
|
+
purpose: 'verify_module:file_select',
|
|
108
|
+
model: spec.model,
|
|
109
|
+
maxTokens: 2000,
|
|
110
|
+
effort: 'low',
|
|
111
|
+
system: spec.system,
|
|
112
|
+
stableText: '',
|
|
113
|
+
text: selectPrompt,
|
|
114
|
+
schema: fileSelectSchema,
|
|
115
|
+
});
|
|
116
|
+
const known = new Set(allFiles);
|
|
117
|
+
const chosen = JSON.parse(selectMsg.text).paths.filter((p) => known.has(p)).slice(0, MAX_CANDIDATE_FILES);
|
|
118
|
+
if (chosen.length === 0) {
|
|
119
|
+
return {
|
|
120
|
+
filesRead: [],
|
|
121
|
+
result: {
|
|
122
|
+
verdict: 'differs',
|
|
123
|
+
specSaid: 'See the module spec above.',
|
|
124
|
+
codeDid: 'No file in the local checkout appears to implement this module.',
|
|
125
|
+
differences: ['Nothing in the local checkout plausibly implements this spec — it may be unbuilt, or the code lives outside this checkout.'],
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const files = (await Promise.all(chosen.map(async (p) => {
|
|
130
|
+
const content = await readLocalFile(repoPath, p);
|
|
131
|
+
return content !== null ? { path: p, content } : null;
|
|
132
|
+
}))).filter((f) => f !== null);
|
|
133
|
+
console.log(` reading ${files.map((f) => f.path).join(', ') || '(nothing readable)'}`);
|
|
134
|
+
// Pass 2: does the code satisfy the spec? spec.system/spec.volatileText/spec.schema are sent
|
|
135
|
+
// by the server unchanged — this is the exact same prompt+schema the server-execution path
|
|
136
|
+
// would use, just pointed at unbounded local file contents instead of truncated GitHub reads.
|
|
137
|
+
const verdictMsg = await callModel({
|
|
138
|
+
purpose: 'verify_module:judge',
|
|
139
|
+
model: spec.model,
|
|
140
|
+
maxTokens: spec.maxTokens,
|
|
141
|
+
effort: spec.effort,
|
|
142
|
+
system: spec.system,
|
|
143
|
+
stableText: spec.stableText,
|
|
144
|
+
text: `${spec.volatileText}\n\n# The actual code, as it currently stands locally\n\n${files.map((f) => `## ${f.path}\n\n\`\`\`\n${f.content}\n\`\`\``).join('\n\n')}`,
|
|
145
|
+
schema: spec.schema,
|
|
146
|
+
});
|
|
147
|
+
return { result: JSON.parse(verdictMsg.text), filesRead: files.map((f) => f.path) };
|
|
148
|
+
}
|
|
149
|
+
/* ------------------------------------------------------------------ generate_spec_batch */
|
|
150
|
+
/** Matches verify_module's own MAX_CANDIDATE_FILES — tried lower (4) first on the theory that a
|
|
151
|
+
* batch multiplies by however many modules it holds, but a real end-to-end test caught it
|
|
152
|
+
* under-reading: a module genuinely spread across companion/, server/, and src/ (this repo's
|
|
153
|
+
* own "Local Companion Agent" module, dogfooded during Phase 3 development) got 4 real files
|
|
154
|
+
* read and produced an accurate spec for what it saw, but never even considered
|
|
155
|
+
* server/companion.ts or src/CompanionModal.tsx — half the actual implementation. Comfortably
|
|
156
|
+
* beats the server path's own FILES_PER_MODULE=2 (bootstrap.ts) either way; this raises the
|
|
157
|
+
* ceiling for the genuinely multi-file case rather than assuming it wouldn't come up. */
|
|
158
|
+
const MAX_FILES_PER_MODULE_IN_BATCH = 8;
|
|
159
|
+
export const batchFileSelectSchema = {
|
|
160
|
+
type: 'object',
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
required: ['selections'],
|
|
163
|
+
properties: {
|
|
164
|
+
selections: {
|
|
165
|
+
type: 'array',
|
|
166
|
+
description: 'One entry per module in the batch, in the same order.',
|
|
167
|
+
items: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
additionalProperties: false,
|
|
170
|
+
required: ['moduleId', 'paths'],
|
|
171
|
+
properties: {
|
|
172
|
+
moduleId: { type: 'string' },
|
|
173
|
+
paths: {
|
|
174
|
+
type: 'array',
|
|
175
|
+
items: { type: 'string' },
|
|
176
|
+
description: `Up to ${MAX_FILES_PER_MODULE_IN_BATCH} repo file paths (verbatim from the tree) most likely to implement this module. Empty if nothing plausibly does.`,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
/** Runs a whole generate_spec_batch task locally — writes full specs for every module in the
|
|
184
|
+
* batch (up to server/bootstrap.ts's BATCH_SIZE, currently 5) in one task. Same two-pass shape
|
|
185
|
+
* as runVerifyModuleTask (pick files, then use them), just per-module in pass 1 and producing
|
|
186
|
+
* the whole specs array in pass 2. `spec.volatileText` already carries the per-module id/lane/
|
|
187
|
+
* purpose breakdown and the "find files yourself" instruction (buildSpecBatchVolatile,
|
|
188
|
+
* server/bootstrap.ts) — this only adds the file tree and, in pass 2, what was actually read. */
|
|
189
|
+
export async function runGenerateSpecBatchTask(callModel, task, repoPath) {
|
|
190
|
+
const payload = task.payload;
|
|
191
|
+
const spec = payload.spec;
|
|
192
|
+
const moduleIds = payload.moduleIds;
|
|
193
|
+
if (payload.connectionless) {
|
|
194
|
+
// The PRD is already in spec.stableText (stableTextForLocalDispatch); there is nothing local to read.
|
|
195
|
+
const writeMsg = await callModel({
|
|
196
|
+
purpose: 'generate_spec_batch:write',
|
|
197
|
+
model: spec.model,
|
|
198
|
+
maxTokens: spec.maxTokens,
|
|
199
|
+
effort: spec.effort,
|
|
200
|
+
system: spec.system,
|
|
201
|
+
stableText: spec.stableText,
|
|
202
|
+
text: spec.volatileText,
|
|
203
|
+
schema: spec.schema,
|
|
204
|
+
});
|
|
205
|
+
return { result: JSON.parse(writeMsg.text), filesRead: [] };
|
|
206
|
+
}
|
|
207
|
+
const allFiles = await listRepoFiles(repoPath);
|
|
208
|
+
console.log(` scanning ${allFiles.length} files in ${repoPath} for ${moduleIds.length} module(s)…`);
|
|
209
|
+
// Pass 1: which files relate to each module in this batch?
|
|
210
|
+
const selectPrompt = `${spec.volatileText}
|
|
211
|
+
|
|
212
|
+
# The repository file tree (${allFiles.length} files)
|
|
213
|
+
|
|
214
|
+
${allFiles.join('\n')}
|
|
215
|
+
|
|
216
|
+
# Before writing specs
|
|
217
|
+
|
|
218
|
+
For EACH module above (by id: ${moduleIds.join(', ')}), list up to ${MAX_FILES_PER_MODULE_IN_BATCH}
|
|
219
|
+
file paths (verbatim from the tree above) most likely to implement it. One entry per module id,
|
|
220
|
+
in the same order as given.`;
|
|
221
|
+
const selectMsg = await callModel({
|
|
222
|
+
purpose: 'generate_spec_batch:file_select',
|
|
223
|
+
model: spec.model,
|
|
224
|
+
maxTokens: 3000,
|
|
225
|
+
effort: 'low',
|
|
226
|
+
system: spec.system,
|
|
227
|
+
stableText: '',
|
|
228
|
+
text: selectPrompt,
|
|
229
|
+
schema: batchFileSelectSchema,
|
|
230
|
+
});
|
|
231
|
+
const known = new Set(allFiles);
|
|
232
|
+
const selections = JSON.parse(selectMsg.text).selections;
|
|
233
|
+
const uniquePaths = [...new Set(selections.flatMap((s) => s.paths.filter((p) => known.has(p)).slice(0, MAX_FILES_PER_MODULE_IN_BATCH)))];
|
|
234
|
+
const fileMap = new Map();
|
|
235
|
+
await Promise.all(uniquePaths.map(async (p) => {
|
|
236
|
+
const content = await readLocalFile(repoPath, p);
|
|
237
|
+
if (content !== null)
|
|
238
|
+
fileMap.set(p, content);
|
|
239
|
+
}));
|
|
240
|
+
console.log(` reading ${[...fileMap.keys()].join(', ') || '(nothing readable)'}`);
|
|
241
|
+
// Pass 2: write the actual specs, grounded in what was read.
|
|
242
|
+
const filesSection = selections
|
|
243
|
+
.map((s) => {
|
|
244
|
+
const found = s.paths.filter((p) => fileMap.has(p));
|
|
245
|
+
return `## Files read for ${s.moduleId}\n\n${found.length ? found.map((p) => `### ${p}\n\`\`\`\n${fileMap.get(p)}\n\`\`\``).join('\n\n') : '(nothing readable found for this module)'}`;
|
|
246
|
+
})
|
|
247
|
+
.join('\n\n');
|
|
248
|
+
const writeMsg = await callModel({
|
|
249
|
+
purpose: 'generate_spec_batch:write',
|
|
250
|
+
model: spec.model,
|
|
251
|
+
maxTokens: spec.maxTokens,
|
|
252
|
+
effort: spec.effort,
|
|
253
|
+
system: spec.system,
|
|
254
|
+
stableText: spec.stableText,
|
|
255
|
+
text: `${spec.volatileText}\n\n# Related files, as found in the local checkout\n\n${filesSection}`,
|
|
256
|
+
schema: spec.schema,
|
|
257
|
+
});
|
|
258
|
+
return { result: JSON.parse(writeMsg.text), filesRead: [...fileMap.keys()] };
|
|
259
|
+
}
|
|
260
|
+
/* ---------------------------------------------------- propose_skeleton / propose_new_capabilities */
|
|
261
|
+
/**
|
|
262
|
+
* Both propose_skeleton and propose_new_capabilities are "shallow whole-repo" tasks — server
|
|
263
|
+
* sends stableText/volatileText built with NO repo content at all (see bootstrap.ts's
|
|
264
|
+
* stableTextForLocalDispatch / buildCapabilityScanVolatile's repoCtx-null branch), because
|
|
265
|
+
* there's nothing GitHub-sourced to send: the whole point of dispatching is that the companion
|
|
266
|
+
* reads the repo itself. Unlike verify_module/generate_spec_batch, there's no per-module drilling
|
|
267
|
+
* here — just one shallow local scan (file tree + README/CHANGELOG/package.json, mirroring
|
|
268
|
+
* server/bootstrap.ts's own RepoContext shape) appended after spec.volatileText, then one model
|
|
269
|
+
* call. filesRead is always empty for these two: neither result shape uses it (check
|
|
270
|
+
* server/index.ts's completeAiJob payloads for /api/setup/skeleton and
|
|
271
|
+
* /api/module-suggestions/scan — neither includes it), so there's nothing meaningful to report.
|
|
272
|
+
*/
|
|
273
|
+
async function runShallowRepoTask(callModel, purpose, spec, repoPath, connectionless = false) {
|
|
274
|
+
let repoSection = '';
|
|
275
|
+
if (!connectionless) {
|
|
276
|
+
const repoCtx = await buildLocalRepoContext(repoPath);
|
|
277
|
+
console.log(` scanning ${repoCtx.fileList.length} files, README ${repoCtx.readme ? 'found' : 'not found'}, CHANGELOG ${repoCtx.changelog ? 'found' : 'not found'}, package.json ${repoCtx.packageJson ? 'found' : 'not found'}`);
|
|
278
|
+
repoSection = `
|
|
279
|
+
# Repository context (your local checkout)
|
|
280
|
+
|
|
281
|
+
## File tree (${repoCtx.fileList.length} files)
|
|
282
|
+
${repoCtx.fileList.join('\n')}
|
|
283
|
+
|
|
284
|
+
## README
|
|
285
|
+
${repoCtx.readme ?? '(none found)'}
|
|
286
|
+
|
|
287
|
+
## CHANGELOG
|
|
288
|
+
${repoCtx.changelog ?? '(none found)'}
|
|
289
|
+
|
|
290
|
+
## package.json
|
|
291
|
+
${repoCtx.packageJson ?? '(none found)'}
|
|
292
|
+
`.trim();
|
|
293
|
+
}
|
|
294
|
+
const message = await callModel({
|
|
295
|
+
purpose,
|
|
296
|
+
model: spec.model,
|
|
297
|
+
maxTokens: spec.maxTokens,
|
|
298
|
+
effort: spec.effort,
|
|
299
|
+
system: spec.system,
|
|
300
|
+
stableText: spec.stableText,
|
|
301
|
+
text: connectionless ? spec.volatileText : `${spec.volatileText}\n\n${repoSection}`,
|
|
302
|
+
schema: spec.schema,
|
|
303
|
+
documentBase64: spec.stableDocumentBase64,
|
|
304
|
+
});
|
|
305
|
+
return { result: spec.schema ? JSON.parse(message.text) : message.text, filesRead: [] };
|
|
306
|
+
}
|
|
307
|
+
/** Forward-compatible fallback for server-created graph/spec reasoning tasks. The server sends
|
|
308
|
+
* the complete self-describing spec, so these calls never need or receive filesystem context. */
|
|
309
|
+
export async function runGenericTask(callModel, task, repoPath) {
|
|
310
|
+
const spec = task.payload?.spec;
|
|
311
|
+
if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
312
|
+
throw new Error(`Cannot run task kind "${task.kind}": payload.spec is missing or not an object.`);
|
|
313
|
+
}
|
|
314
|
+
return runShallowRepoTask(callModel, 'generic', spec, repoPath, true);
|
|
315
|
+
}
|
|
316
|
+
export async function runProposeSkeletonTask(callModel, task, repoPath) {
|
|
317
|
+
const payload = task.payload;
|
|
318
|
+
return runShallowRepoTask(callModel, 'propose_skeleton', payload.spec, repoPath, payload.connectionless);
|
|
319
|
+
}
|
|
320
|
+
export async function runProposeNewCapabilitiesTask(callModel, task, repoPath) {
|
|
321
|
+
const payload = task.payload;
|
|
322
|
+
return runShallowRepoTask(callModel, 'propose_new_capabilities', payload.spec, repoPath);
|
|
323
|
+
}
|
|
324
|
+
/* --------------------------------------------------------------------- design_prompt */
|
|
325
|
+
/**
|
|
326
|
+
* The one task kind with no schema — spec.schema is undefined, so the model's raw text answer
|
|
327
|
+
* IS the result, never JSON.parse'd (unlike every other task kind here). Looks for local design
|
|
328
|
+
* signal (findDesignFiles, copied verbatim from server/design-context.ts — see design-context.ts's
|
|
329
|
+
* own comment) and appends it if found; spec.volatileText already carries the right instructions
|
|
330
|
+
* for both the "honor what you find" and "propose from scratch" cases (see export.ts's
|
|
331
|
+
* generateDesignPrompt — the local-dispatch branch decides which wording to send based on the
|
|
332
|
+
* same `direction` the server path already respects, before this ever reaches the companion).
|
|
333
|
+
*/
|
|
334
|
+
export async function runDesignPromptTask(callModel, task, repoPath) {
|
|
335
|
+
const payload = task.payload;
|
|
336
|
+
const spec = payload.spec;
|
|
337
|
+
const designContext = await buildLocalDesignContext(repoPath);
|
|
338
|
+
console.log(` design signal: ${designContext ? 'found' : 'none found'}`);
|
|
339
|
+
const message = await callModel({
|
|
340
|
+
purpose: 'design_prompt',
|
|
341
|
+
model: spec.model,
|
|
342
|
+
maxTokens: spec.maxTokens,
|
|
343
|
+
effort: spec.effort,
|
|
344
|
+
system: spec.system,
|
|
345
|
+
stableText: spec.stableText,
|
|
346
|
+
text: designContext ? `${spec.volatileText}\n\n# Design signal found in your local checkout\n\n${designContext}` : spec.volatileText,
|
|
347
|
+
schema: spec.schema,
|
|
348
|
+
});
|
|
349
|
+
return { result: message.text, filesRead: [] }; // filesRead unused for this kind — see propose_skeleton/propose_new_capabilities's own comment
|
|
350
|
+
}
|
|
351
|
+
/* --------------------------------------------------------------------- analyse_shipment */
|
|
352
|
+
/**
|
|
353
|
+
* Runs a whole analyse_shipment task locally. Unlike every other task kind above, this one's
|
|
354
|
+
* "find the relevant content" step is git-level, not file-level (prepareShipmentAnalysis, see
|
|
355
|
+
* shipment.ts), and can conclude the task WITHOUT ever calling the model at all — a commit the
|
|
356
|
+
* companion's own local looksTrivial filter rejects is reported straight back as an empty
|
|
357
|
+
* result ({touched: [], possibleNewModules: []}), exactly the shape driftSchema already treats
|
|
358
|
+
* as a valid "nothing plausible here" answer, so there's no separate result shape to teach the
|
|
359
|
+
* server about. sha_not_local throws — the caller (cli.ts's runOneTask) reports it as a task
|
|
360
|
+
* error via POST .../error, which surfaces to checkConnection as one caught, per-commit failure
|
|
361
|
+
* (see server/poller.ts's own comment), not a crash that would wedge the rest of that check.
|
|
362
|
+
*/
|
|
363
|
+
export async function runAnalyseShipmentTask(callModel, task, repoPath) {
|
|
364
|
+
const payload = task.payload;
|
|
365
|
+
const prep = await prepareShipmentAnalysis(repoPath, payload.sha);
|
|
366
|
+
if (prep.outcome === 'sha_not_local') {
|
|
367
|
+
throw new Error(`sha_not_local: commit ${payload.sha} was not found in the local checkout at ${repoPath}, even after a git fetch.`);
|
|
368
|
+
}
|
|
369
|
+
if (prep.outcome === 'trivial') {
|
|
370
|
+
console.log(` ${payload.sha.slice(0, 7)} looks trivial (lockfile/CI-config/build-output only) — skipping the AI call`);
|
|
371
|
+
return { result: { touched: [], possibleNewModules: [] }, filesRead: [] };
|
|
372
|
+
}
|
|
373
|
+
console.log(` read ${payload.sha.slice(0, 7)}'s diff locally (${prep.diffText.length} chars, ${prep.changedPaths.length} file(s) changed)`);
|
|
374
|
+
const spec = appendDiffToVolatile(payload.spec, prep.diffText, prep.changedPaths);
|
|
375
|
+
const message = await callModel({
|
|
376
|
+
purpose: 'analyse_shipment',
|
|
377
|
+
model: spec.model,
|
|
378
|
+
maxTokens: spec.maxTokens,
|
|
379
|
+
effort: spec.effort,
|
|
380
|
+
system: spec.system,
|
|
381
|
+
stableText: spec.stableText,
|
|
382
|
+
text: spec.volatileText,
|
|
383
|
+
schema: spec.schema,
|
|
384
|
+
});
|
|
385
|
+
// filesRead unused for this kind, same as propose_skeleton/propose_new_capabilities above —
|
|
386
|
+
// dispatchAndAwaitLocalTask's caller (poller.ts) only consumes the parsed result, not filesRead.
|
|
387
|
+
return { result: JSON.parse(message.text), filesRead: [] };
|
|
388
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import crossSpawn from 'cross-spawn';
|
|
2
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
5
|
+
const CMD_UNSAFE_ARGUMENT = /[\^<>|&%!]/;
|
|
6
|
+
const FALLBACK_ARGUMENT_ERROR = "claude resolves to a .cmd shim and this argument can't pass through cmd.exe intact; install Claude Code so claude.exe is reachable, or update the companion";
|
|
7
|
+
const defaultFs = { existsSync, readFileSync };
|
|
8
|
+
let cachedCommand = null;
|
|
9
|
+
function envValue(env, name) {
|
|
10
|
+
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
11
|
+
return key ? env[key] : undefined;
|
|
12
|
+
}
|
|
13
|
+
function windowsPathEntries(env) {
|
|
14
|
+
return (envValue(env, 'PATH') ?? '')
|
|
15
|
+
.split(';')
|
|
16
|
+
.map((entry) => entry.trim().replace(/^"|"$/g, ''))
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
function windowsExtensions(env) {
|
|
20
|
+
const pathExt = envValue(env, 'PATHEXT') || '.COM;.EXE;.BAT;.CMD';
|
|
21
|
+
return pathExt
|
|
22
|
+
.split(';')
|
|
23
|
+
.map((extension) => extension.trim().toLowerCase())
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map((extension) => extension.startsWith('.') ? extension : `.${extension}`);
|
|
26
|
+
}
|
|
27
|
+
function existingCandidate(directory, extension, fs) {
|
|
28
|
+
const lowerCaseCandidate = join(directory, `claude${extension.toLowerCase()}`);
|
|
29
|
+
if (fs.existsSync(lowerCaseCandidate))
|
|
30
|
+
return resolve(lowerCaseCandidate);
|
|
31
|
+
const originalCaseCandidate = join(directory, `claude${extension}`);
|
|
32
|
+
return fs.existsSync(originalCaseCandidate) ? resolve(originalCaseCandidate) : null;
|
|
33
|
+
}
|
|
34
|
+
function parsedShimExecutable(shimPath, fs) {
|
|
35
|
+
let contents;
|
|
36
|
+
try {
|
|
37
|
+
contents = fs.readFileSync(shimPath, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const shimDirectory = dirname(shimPath);
|
|
43
|
+
for (const match of contents.matchAll(/"([^"\r\n]*claude\.exe)"/gi)) {
|
|
44
|
+
let target = match[1].replace(/%(?:~dp0|dp0)%?/gi, shimDirectory);
|
|
45
|
+
// Tests inject win32 while running on Linux CI; normalizing both separator forms keeps the
|
|
46
|
+
// resolver testable with a real temporary filesystem and is a no-op for native Windows paths.
|
|
47
|
+
target = target.replace(/[\\/]/g, sep);
|
|
48
|
+
if (!isAbsolute(target))
|
|
49
|
+
target = resolve(shimDirectory, target);
|
|
50
|
+
if (fs.existsSync(target))
|
|
51
|
+
return resolve(target);
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
function executableForShim(shimPath, fs) {
|
|
56
|
+
const parsed = parsedShimExecutable(shimPath, fs);
|
|
57
|
+
if (parsed)
|
|
58
|
+
return parsed;
|
|
59
|
+
const conventional = join(dirname(shimPath), 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
|
|
60
|
+
return fs.existsSync(conventional) ? resolve(conventional) : null;
|
|
61
|
+
}
|
|
62
|
+
function resolveUncached(platform, env, fs) {
|
|
63
|
+
if (platform !== 'win32') {
|
|
64
|
+
return { command: { command: 'claude', viaShell: true }, fallbackReason: null };
|
|
65
|
+
}
|
|
66
|
+
const directories = windowsPathEntries(env);
|
|
67
|
+
const extensions = windowsExtensions(env);
|
|
68
|
+
for (const directory of directories) {
|
|
69
|
+
for (const extension of extensions) {
|
|
70
|
+
if (extension !== '.cmd' && extension !== '.exe')
|
|
71
|
+
continue;
|
|
72
|
+
const candidate = existingCandidate(directory, extension, fs);
|
|
73
|
+
if (!candidate)
|
|
74
|
+
continue;
|
|
75
|
+
if (extension === '.exe') {
|
|
76
|
+
return { command: { command: candidate, viaShell: false }, fallbackReason: null };
|
|
77
|
+
}
|
|
78
|
+
const executable = executableForShim(candidate, fs);
|
|
79
|
+
if (executable) {
|
|
80
|
+
return { command: { command: executable, viaShell: false }, fallbackReason: null };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
command: { command: 'claude', viaShell: true },
|
|
84
|
+
fallbackReason: `Found ${candidate}, but its claude.exe target was not reachable; falling back to the .cmd shim.`,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
command: { command: 'claude', viaShell: true },
|
|
90
|
+
fallbackReason: 'No claude.exe or claude.cmd was found on PATH; falling back to cross-spawn command lookup.',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Resolve once in production. Injected dependencies bypass the cache so Linux CI can exercise
|
|
94
|
+
* the Windows lookup rules without mutating process globals or depending on Windows filesystem
|
|
95
|
+
* semantics. */
|
|
96
|
+
export function resolveClaudeCommand(options = {}) {
|
|
97
|
+
const injected = options.platform !== undefined || options.env !== undefined || options.fs !== undefined;
|
|
98
|
+
if (!injected && cachedCommand)
|
|
99
|
+
return cachedCommand;
|
|
100
|
+
const result = resolveUncached(options.platform ?? process.platform, options.env ?? process.env, options.fs ?? defaultFs);
|
|
101
|
+
if (!injected) {
|
|
102
|
+
cachedCommand = result.command;
|
|
103
|
+
if (result.fallbackReason)
|
|
104
|
+
console.warn(`[truing] ${result.fallbackReason}`);
|
|
105
|
+
}
|
|
106
|
+
return result.command;
|
|
107
|
+
}
|
|
108
|
+
export function assertClaudeArgumentsSafeForFallback(args, platform = process.platform) {
|
|
109
|
+
if (platform === 'win32' && args.some((argument) => CMD_UNSAFE_ARGUMENT.test(argument))) {
|
|
110
|
+
throw new Error(FALLBACK_ARGUMENT_ERROR);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Native executables go straight through CreateProcess via Node. cross-spawn remains only for
|
|
114
|
+
* the unresolved command-name path, where it supplies the existing .cmd compatibility behavior. */
|
|
115
|
+
export function spawnClaude(args, options = {}, resolution = resolveClaudeCommand()) {
|
|
116
|
+
const hiddenOptions = { ...options, windowsHide: true };
|
|
117
|
+
if (resolution.viaShell) {
|
|
118
|
+
assertClaudeArgumentsSafeForFallback(args);
|
|
119
|
+
return crossSpawn(resolution.command, args, hiddenOptions);
|
|
120
|
+
}
|
|
121
|
+
return nodeSpawn(resolution.command, args, hiddenOptions);
|
|
122
|
+
}
|