product-console-companion 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fynch Consulting
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # product-console-companion
2
+
3
+ A local-first BYOS companion for the [Fynch Product Console](../README.md). Reads code from
4
+ your own machine, so:
5
+
6
+ - code never leaves your machine — only a small structured verdict (matches/differs + a short
7
+ explanation) is ever sent back;
8
+ - there's no server-side truncation on what gets read (`server/drift.ts`'s
9
+ `MAX_FILE_CHARS`/`MAX_TREE_PATHS` don't apply here at all);
10
+ - AI spend bills against your own key/subscription, not the workspace's shared/BYOK credential.
11
+
12
+ Covers every AI call the hosted Console makes that reads your code — six task kinds as of this
13
+ release (`verify_module` — "Check against code" on a module card; `generate_spec_batch` —
14
+ writing full specs during setup; `propose_skeleton` and `propose_new_capabilities` — the two
15
+ shallow whole-repo scans; `design_prompt` — the free-text export; `analyse_shipment` — the
16
+ passive per-commit drift check behind the GitHub webhook and the "Check GitHub" button), two
17
+ backends, chosen per connection at `connect` time:
18
+
19
+ - **`api-key`** (default) — no CLI dependency. Calls Anthropic directly with your own API key
20
+ (`api-key-caller.ts`), running its own two-pass file-select-then-judge, mirroring
21
+ `server/drift.ts`'s `verifyModuleAgainstCode` but reading local files unbounded.
22
+ - **`cli`** — shells out to an already-authenticated `claude` CLI (`claude-cli-caller.ts`) for
23
+ full agentic Read/Grep/Glob depth in one call — the model does its own exploring instead of a
24
+ separate file-select pass. Bills against whatever the CLI is authenticated with (a Claude
25
+ subscription via `claude setup-token`, or `ANTHROPIC_API_KEY` if the CLI has that set). Runs
26
+ against a disposable, read-only git worktree (`sandbox.ts`) as defense-in-depth on top of
27
+ `--restricted --tools "Read,Grep,Glob"`, which should already make a write/exec structurally
28
+ impossible on its own.
29
+
30
+ ## Setup
31
+
32
+ ```bash
33
+ npx product-console-companion connect \
34
+ --url https://console.fynchconsulting.com \
35
+ --token <publicId>.<secret> \
36
+ --connection <connectionId> \
37
+ --path /path/to/your/local/checkout \
38
+ --backend api-key # or: --backend cli
39
+ ```
40
+
41
+ Get `--token`/`--connection` from the Console's **Companion** button (generates a token and a
42
+ ready-to-paste version of this command) — see `src/CompanionModal.tsx` in the parent repo.
43
+ Running `connect` again for a different `--connection`/`--path` (same `--url`/`--token`) adds a
44
+ second connection to the same local config; one companion process can serve several repos, each
45
+ with its own backend choice.
46
+
47
+ For `--backend api-key`: `export ANTHROPIC_API_KEY=sk-ant-...` (get one at console.anthropic.com).
48
+ For `--backend cli`: install and authenticate the CLI first — `npm install -g
49
+ @anthropic-ai/claude-code`, then `claude` (interactive login) or set `ANTHROPIC_API_KEY` for it.
50
+ `connect` checks `claude --version` up front and fails fast if it's missing.
51
+
52
+ Then, in the Console's **Manage connected repos**, switch that repo's execution to "On your
53
+ machine, via companion". Finally, to actually serve tasks:
54
+
55
+ ```bash
56
+ npx product-console-companion run
57
+ ```
58
+
59
+ Polls each configured connection every few seconds until Ctrl+C. Nothing runs while this isn't
60
+ running — see the parent plan's Section 7 for why that's deliberate (goal 4: no AI spend while
61
+ you're not around to notice).
62
+
63
+ ## What's NOT yet implemented (be aware before relying on this)
64
+
65
+ - **Per-task budget is not enforced client-side for the `api-key` backend.** Every dispatched
66
+ task carries a `maxBudgetUsd` (visible in the `run` log line); `api-key-caller.ts` does not
67
+ track spend against it or abort a call that exceeds it. The `cli` backend passes it straight
68
+ through as `claude`'s own `--max-budget-usd`, which the CLI does enforce (confirmed
69
+ empirically: it stops and reports `terminal_reason: 'budget_exhausted'`).
70
+ - **No wall-clock timeout for the `api-key` backend** — a hung request blocks that connection's
71
+ polling loop. The `cli` backend has one (10 minutes, independent of the budget cap — a dollar
72
+ ceiling doesn't stop a network-stall hang).
73
+ - **Git-fetch-if-missing is `analyse_shipment`-only.** Every other task kind reads whatever HEAD
74
+ currently is in `--path` (or, for `cli`, a worktree pinned to that HEAD) — correct for those,
75
+ since they check the *current* state of your code, not a specific historical commit.
76
+ `analyse_shipment` is different: it's about one specific commit a webhook or the hosted poller
77
+ told the server about, which the server never fetches for you. It verifies that commit is
78
+ actually present locally first, attempts one `git fetch` (your own git credentials — never
79
+ anything from the hosted server) if it isn't, and reports back distinctly (`sha_not_local`)
80
+ rather than silently analysing the wrong tree state if it still can't find it.
81
+ - **`cli` backend's sandbox worktree isolates writes, not reads** — `--add-dir` points at the
82
+ disposable worktree, so a read sees exactly what's committed at HEAD, including anything
83
+ `.gitignore`d being invisible (same as `git ls-files`'s own behavior). Uncommitted local
84
+ changes in your real checkout are never visible to the CLI backend — worth knowing if you're
85
+ expecting a check to see work-in-progress edits. (`analyse_shipment`'s own diff read is the one
86
+ exception: it happens against your real checkout, via `git show`, before the sandbox is even
87
+ created — the model itself still only ever sees the sandboxed copy.)
88
+
89
+ ## Local development (inside the parent repo)
90
+
91
+ ```bash
92
+ cd companion
93
+ npm install
94
+ npm run dev -- connect --url http://localhost:3101 --token <publicId>.<secret> --connection <connectionId> --path <repo path> --backend api-key
95
+ npm run dev -- run
96
+ ```
@@ -0,0 +1,371 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { buildLocalDesignContext } from './design-context.js';
4
+ import { buildLocalRepoContext, listRepoFiles } from './local-scan.js';
5
+ import { appendDiffToVolatile, prepareShipmentAnalysis } from './shipment.js';
6
+ const MAX_CANDIDATE_FILES = 8;
7
+ /** A skip threshold, not a truncation — a file over this is left OUT of what's read entirely
8
+ * (with a note in the log), rather than read and cut off partway through. The whole point of
9
+ * this backend is that what IS read is never truncated; this only guards against an
10
+ * accidentally-selected generated/binary/data file blowing up the prompt. */
11
+ const MAX_FILE_BYTES = 512 * 1024;
12
+ const fileSelectSchema = {
13
+ type: 'object',
14
+ additionalProperties: false,
15
+ required: ['paths'],
16
+ properties: {
17
+ paths: {
18
+ type: 'array',
19
+ items: { type: 'string' },
20
+ 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.`,
21
+ },
22
+ },
23
+ };
24
+ async function readLocalFile(repoPath, relPath) {
25
+ try {
26
+ const buf = await readFile(join(repoPath, relPath));
27
+ if (buf.byteLength > MAX_FILE_BYTES) {
28
+ 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`);
29
+ return null;
30
+ }
31
+ return buf.toString('utf8');
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** Runs the whole verify_module task locally and returns exactly what
38
+ * POST /api/companion/tasks/:id/result expects — `result` validated server-side against the
39
+ * exact schema the task shipped with (task.payload.spec.schema), `filesRead` for the UI's
40
+ * provenance note. Throws on anything that should surface as a task error (caller reports it
41
+ * via POST .../error). */
42
+ export async function runVerifyModuleTask(client, task, repoPath) {
43
+ const payload = task.payload;
44
+ const spec = payload.spec;
45
+ const allFiles = await listRepoFiles(repoPath);
46
+ console.log(` scanning ${allFiles.length} files in ${repoPath}…`);
47
+ // Pass 1: which files implement this module? Reuses the module-spec text the server already
48
+ // wrote into spec.volatileText (buildVerifyModuleTaskSpec) as context — it already contains
49
+ // the full spec, so this doesn't need to re-derive it.
50
+ const selectPrompt = `${spec.volatileText}
51
+
52
+ # The repository file tree (${allFiles.length} files)
53
+
54
+ ${allFiles.join('\n')}
55
+
56
+ # Before judging
57
+
58
+ First, list the file paths (verbatim from the tree above, up to ${MAX_CANDIDATE_FILES}) most
59
+ likely to implement this module's behaviour. Prefer application source over tests, config, and
60
+ generated files. Empty list if nothing plausibly implements it.`;
61
+ const selectStream = client.messages.stream({
62
+ model: spec.model,
63
+ max_tokens: 2000,
64
+ thinking: { type: 'adaptive' },
65
+ output_config: { effort: 'low', format: { type: 'json_schema', schema: fileSelectSchema } },
66
+ system: spec.system,
67
+ messages: [{ role: 'user', content: selectPrompt }],
68
+ });
69
+ const selectMsg = await selectStream.finalMessage();
70
+ const selectText = selectMsg.content.find((b) => b.type === 'text');
71
+ if (!selectText || selectText.type !== 'text')
72
+ throw new Error('Could not pick files to check (no text in the file-select response).');
73
+ const known = new Set(allFiles);
74
+ const chosen = JSON.parse(selectText.text).paths.filter((p) => known.has(p)).slice(0, MAX_CANDIDATE_FILES);
75
+ if (chosen.length === 0) {
76
+ return {
77
+ filesRead: [],
78
+ result: {
79
+ verdict: 'differs',
80
+ specSaid: 'See the module spec above.',
81
+ codeDid: 'No file in the local checkout appears to implement this module.',
82
+ differences: ['Nothing in the local checkout plausibly implements this spec — it may be unbuilt, or the code lives outside this checkout.'],
83
+ },
84
+ };
85
+ }
86
+ const files = (await Promise.all(chosen.map(async (p) => {
87
+ const content = await readLocalFile(repoPath, p);
88
+ return content !== null ? { path: p, content } : null;
89
+ }))).filter((f) => f !== null);
90
+ console.log(` reading ${files.map((f) => f.path).join(', ') || '(nothing readable)'}`);
91
+ // Pass 2: does the code satisfy the spec? spec.system/spec.volatileText/spec.schema are sent
92
+ // by the server unchanged — this is the exact same prompt+schema the server-execution path
93
+ // would use, just pointed at unbounded local file contents instead of truncated GitHub reads.
94
+ const content = [];
95
+ if (spec.stableText)
96
+ content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
97
+ content.push({
98
+ type: 'text',
99
+ 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')}`,
100
+ });
101
+ const verdictStream = client.messages.stream({
102
+ model: spec.model,
103
+ max_tokens: spec.maxTokens,
104
+ thinking: { type: 'adaptive' },
105
+ output_config: spec.schema
106
+ ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } }
107
+ : { effort: spec.effort },
108
+ system: spec.system,
109
+ messages: [{ role: 'user', content }],
110
+ });
111
+ const verdictMsg = await verdictStream.finalMessage();
112
+ if (verdictMsg.stop_reason === 'refusal')
113
+ throw new Error('The model declined to check this module.');
114
+ const verdictText = verdictMsg.content.find((b) => b.type === 'text');
115
+ if (!verdictText || verdictText.type !== 'text')
116
+ throw new Error('No verdict returned.');
117
+ return { result: JSON.parse(verdictText.text), filesRead: files.map((f) => f.path) };
118
+ }
119
+ /* ------------------------------------------------------------------ generate_spec_batch */
120
+ /** Matches verify_module's own MAX_CANDIDATE_FILES — tried lower (4) first on the theory that a
121
+ * batch multiplies by however many modules it holds, but a real end-to-end test caught it
122
+ * under-reading: a module genuinely spread across companion/, server/, and src/ (this repo's
123
+ * own "Local Companion Agent" module, dogfooded during Phase 3 development) got 4 real files
124
+ * read and produced an accurate spec for what it saw, but never even considered
125
+ * server/companion.ts or src/CompanionModal.tsx — half the actual implementation. Comfortably
126
+ * beats the server path's own FILES_PER_MODULE=2 (bootstrap.ts) either way; this raises the
127
+ * ceiling for the genuinely multi-file case rather than assuming it wouldn't come up. */
128
+ const MAX_FILES_PER_MODULE_IN_BATCH = 8;
129
+ const batchFileSelectSchema = {
130
+ type: 'object',
131
+ additionalProperties: false,
132
+ required: ['selections'],
133
+ properties: {
134
+ selections: {
135
+ type: 'array',
136
+ description: 'One entry per module in the batch, in the same order.',
137
+ items: {
138
+ type: 'object',
139
+ additionalProperties: false,
140
+ required: ['moduleId', 'paths'],
141
+ properties: {
142
+ moduleId: { type: 'string' },
143
+ paths: {
144
+ type: 'array',
145
+ items: { type: 'string' },
146
+ 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.`,
147
+ },
148
+ },
149
+ },
150
+ },
151
+ },
152
+ };
153
+ /** Runs a whole generate_spec_batch task locally — writes full specs for every module in the
154
+ * batch (up to server/bootstrap.ts's BATCH_SIZE, currently 5) in one task. Same two-pass shape
155
+ * as runVerifyModuleTask (pick files, then use them), just per-module in pass 1 and producing
156
+ * the whole specs array in pass 2. `spec.volatileText` already carries the per-module id/lane/
157
+ * purpose breakdown and the "find files yourself" instruction (buildSpecBatchVolatile,
158
+ * server/bootstrap.ts) — this only adds the file tree and, in pass 2, what was actually read. */
159
+ export async function runGenerateSpecBatchTask(client, task, repoPath) {
160
+ const payload = task.payload;
161
+ const spec = payload.spec;
162
+ const moduleIds = payload.moduleIds;
163
+ const allFiles = await listRepoFiles(repoPath);
164
+ console.log(` scanning ${allFiles.length} files in ${repoPath} for ${moduleIds.length} module(s)…`);
165
+ // Pass 1: which files relate to each module in this batch?
166
+ const selectPrompt = `${spec.volatileText}
167
+
168
+ # The repository file tree (${allFiles.length} files)
169
+
170
+ ${allFiles.join('\n')}
171
+
172
+ # Before writing specs
173
+
174
+ For EACH module above (by id: ${moduleIds.join(', ')}), list up to ${MAX_FILES_PER_MODULE_IN_BATCH}
175
+ file paths (verbatim from the tree above) most likely to implement it. One entry per module id,
176
+ in the same order as given.`;
177
+ const selectStream = client.messages.stream({
178
+ model: spec.model,
179
+ max_tokens: 3000,
180
+ thinking: { type: 'adaptive' },
181
+ output_config: { effort: 'low', format: { type: 'json_schema', schema: batchFileSelectSchema } },
182
+ system: spec.system,
183
+ messages: [{ role: 'user', content: selectPrompt }],
184
+ });
185
+ const selectMsg = await selectStream.finalMessage();
186
+ const selectText = selectMsg.content.find((b) => b.type === 'text');
187
+ if (!selectText || selectText.type !== 'text')
188
+ throw new Error('Could not pick files for this batch (no text in the file-select response).');
189
+ const known = new Set(allFiles);
190
+ const selections = JSON.parse(selectText.text).selections;
191
+ const uniquePaths = [...new Set(selections.flatMap((s) => s.paths.filter((p) => known.has(p)).slice(0, MAX_FILES_PER_MODULE_IN_BATCH)))];
192
+ const fileMap = new Map();
193
+ await Promise.all(uniquePaths.map(async (p) => {
194
+ const content = await readLocalFile(repoPath, p);
195
+ if (content !== null)
196
+ fileMap.set(p, content);
197
+ }));
198
+ console.log(` reading ${[...fileMap.keys()].join(', ') || '(nothing readable)'}`);
199
+ // Pass 2: write the actual specs, grounded in what was read.
200
+ const filesSection = selections
201
+ .map((s) => {
202
+ const found = s.paths.filter((p) => fileMap.has(p));
203
+ 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)'}`;
204
+ })
205
+ .join('\n\n');
206
+ const content = [];
207
+ if (spec.stableText)
208
+ content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
209
+ content.push({ type: 'text', text: `${spec.volatileText}\n\n# Related files, as found in the local checkout\n\n${filesSection}` });
210
+ const writeStream = client.messages.stream({
211
+ model: spec.model,
212
+ max_tokens: spec.maxTokens,
213
+ thinking: { type: 'adaptive' },
214
+ output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
215
+ system: spec.system,
216
+ messages: [{ role: 'user', content }],
217
+ });
218
+ const writeMsg = await writeStream.finalMessage();
219
+ if (writeMsg.stop_reason === 'refusal')
220
+ throw new Error('The model declined to write specs for this batch.');
221
+ const writeText = writeMsg.content.find((b) => b.type === 'text');
222
+ if (!writeText || writeText.type !== 'text')
223
+ throw new Error('No specs returned.');
224
+ return { result: JSON.parse(writeText.text), filesRead: [...fileMap.keys()] };
225
+ }
226
+ /* ---------------------------------------------------- propose_skeleton / propose_new_capabilities */
227
+ /**
228
+ * Both propose_skeleton and propose_new_capabilities are "shallow whole-repo" tasks — server
229
+ * sends stableText/volatileText built with NO repo content at all (see bootstrap.ts's
230
+ * stableTextForLocalDispatch / buildCapabilityScanVolatile's repoCtx-null branch), because
231
+ * there's nothing GitHub-sourced to send: the whole point of dispatching is that the companion
232
+ * reads the repo itself. Unlike verify_module/generate_spec_batch, there's no per-module drilling
233
+ * here — just one shallow local scan (file tree + README/CHANGELOG/package.json, mirroring
234
+ * server/bootstrap.ts's own RepoContext shape) appended after spec.volatileText, then one model
235
+ * call. filesRead is always empty for these two: neither result shape uses it (check
236
+ * server/index.ts's completeAiJob payloads for /api/setup/skeleton and
237
+ * /api/module-suggestions/scan — neither includes it), so there's nothing meaningful to report.
238
+ */
239
+ async function runShallowRepoTask(client, spec, repoPath) {
240
+ const repoCtx = await buildLocalRepoContext(repoPath);
241
+ 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'}`);
242
+ const repoSection = `
243
+ # Repository context (your local checkout)
244
+
245
+ ## File tree (${repoCtx.fileList.length} files)
246
+ ${repoCtx.fileList.join('\n')}
247
+
248
+ ## README
249
+ ${repoCtx.readme ?? '(none found)'}
250
+
251
+ ## CHANGELOG
252
+ ${repoCtx.changelog ?? '(none found)'}
253
+
254
+ ## package.json
255
+ ${repoCtx.packageJson ?? '(none found)'}
256
+ `.trim();
257
+ const content = [];
258
+ if (spec.stableDocumentBase64) {
259
+ content.push({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: spec.stableDocumentBase64 } });
260
+ }
261
+ if (spec.stableText)
262
+ content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
263
+ content.push({ type: 'text', text: `${spec.volatileText}\n\n${repoSection}` });
264
+ const stream = client.messages.stream({
265
+ model: spec.model,
266
+ max_tokens: spec.maxTokens,
267
+ thinking: { type: 'adaptive' },
268
+ output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
269
+ system: spec.system,
270
+ messages: [{ role: 'user', content }],
271
+ });
272
+ const message = await stream.finalMessage();
273
+ if (message.stop_reason === 'refusal')
274
+ throw new Error('The model declined this request.');
275
+ const text = message.content.find((b) => b.type === 'text');
276
+ if (!text || text.type !== 'text')
277
+ throw new Error('No result returned.');
278
+ return { result: JSON.parse(text.text), filesRead: [] };
279
+ }
280
+ export async function runProposeSkeletonTask(client, task, repoPath) {
281
+ const payload = task.payload;
282
+ return runShallowRepoTask(client, payload.spec, repoPath);
283
+ }
284
+ export async function runProposeNewCapabilitiesTask(client, task, repoPath) {
285
+ const payload = task.payload;
286
+ return runShallowRepoTask(client, payload.spec, repoPath);
287
+ }
288
+ /* --------------------------------------------------------------------- design_prompt */
289
+ /**
290
+ * The one task kind with no schema — spec.schema is undefined, so the model's raw text answer
291
+ * IS the result, never JSON.parse'd (unlike every other task kind here). Looks for local design
292
+ * signal (findDesignFiles, copied verbatim from server/design-context.ts — see design-context.ts's
293
+ * own comment) and appends it if found; spec.volatileText already carries the right instructions
294
+ * for both the "honor what you find" and "propose from scratch" cases (see export.ts's
295
+ * generateDesignPrompt — the local-dispatch branch decides which wording to send based on the
296
+ * same `direction` the server path already respects, before this ever reaches the companion).
297
+ */
298
+ export async function runDesignPromptTask(client, task, repoPath) {
299
+ const payload = task.payload;
300
+ const spec = payload.spec;
301
+ const designContext = await buildLocalDesignContext(repoPath);
302
+ console.log(` design signal: ${designContext ? 'found' : 'none found'}`);
303
+ const content = [];
304
+ if (spec.stableText)
305
+ content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
306
+ content.push({
307
+ type: 'text',
308
+ text: designContext ? `${spec.volatileText}\n\n# Design signal found in your local checkout\n\n${designContext}` : spec.volatileText,
309
+ });
310
+ const stream = client.messages.stream({
311
+ model: spec.model,
312
+ max_tokens: spec.maxTokens,
313
+ thinking: { type: 'adaptive' },
314
+ output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
315
+ system: spec.system,
316
+ messages: [{ role: 'user', content }],
317
+ });
318
+ const message = await stream.finalMessage();
319
+ if (message.stop_reason === 'refusal')
320
+ throw new Error('The model declined to write a design prompt.');
321
+ const text = message.content.find((b) => b.type === 'text');
322
+ if (!text || text.type !== 'text')
323
+ throw new Error('No design prompt returned.');
324
+ return { result: text.text, filesRead: [] }; // filesRead unused for this kind — see propose_skeleton/propose_new_capabilities's own comment
325
+ }
326
+ /* --------------------------------------------------------------------- analyse_shipment */
327
+ /**
328
+ * Runs a whole analyse_shipment task locally. Unlike every other task kind above, this one's
329
+ * "find the relevant content" step is git-level, not file-level (prepareShipmentAnalysis, see
330
+ * shipment.ts), and can conclude the task WITHOUT ever calling the model at all — a commit the
331
+ * companion's own local looksTrivial filter rejects is reported straight back as an empty
332
+ * result ({touched: [], possibleNewModules: []}), exactly the shape driftSchema already treats
333
+ * as a valid "nothing plausible here" answer, so there's no separate result shape to teach the
334
+ * server about. sha_not_local throws — the caller (cli.ts's runOneTask) reports it as a task
335
+ * error via POST .../error, which surfaces to checkConnection as one caught, per-commit failure
336
+ * (see server/poller.ts's own comment), not a crash that would wedge the rest of that check.
337
+ */
338
+ export async function runAnalyseShipmentTask(client, task, repoPath) {
339
+ const payload = task.payload;
340
+ const prep = await prepareShipmentAnalysis(repoPath, payload.sha);
341
+ if (prep.outcome === 'sha_not_local') {
342
+ throw new Error(`sha_not_local: commit ${payload.sha} was not found in the local checkout at ${repoPath}, even after a git fetch.`);
343
+ }
344
+ if (prep.outcome === 'trivial') {
345
+ console.log(` ${payload.sha.slice(0, 7)} looks trivial (lockfile/CI-config/build-output only) — skipping the AI call`);
346
+ return { result: { touched: [], possibleNewModules: [] }, filesRead: [] };
347
+ }
348
+ console.log(` read ${payload.sha.slice(0, 7)}'s diff locally (${prep.diffText.length} chars, ${prep.changedPaths.length} file(s) changed)`);
349
+ const spec = appendDiffToVolatile(payload.spec, prep.diffText, prep.changedPaths);
350
+ const content = [];
351
+ if (spec.stableText)
352
+ content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
353
+ content.push({ type: 'text', text: spec.volatileText });
354
+ const stream = client.messages.stream({
355
+ model: spec.model,
356
+ max_tokens: spec.maxTokens,
357
+ thinking: { type: 'adaptive' },
358
+ output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
359
+ system: spec.system,
360
+ messages: [{ role: 'user', content }],
361
+ });
362
+ const message = await stream.finalMessage();
363
+ if (message.stop_reason === 'refusal')
364
+ throw new Error('The model declined to analyse this shipment.');
365
+ const text = message.content.find((b) => b.type === 'text');
366
+ if (!text || text.type !== 'text')
367
+ throw new Error('No drift analysis returned.');
368
+ // filesRead unused for this kind, same as propose_skeleton/propose_new_capabilities above —
369
+ // dispatchAndAwaitLocalTask's caller (poller.ts) only consumes the parsed result, not filesRead.
370
+ return { result: JSON.parse(text.text), filesRead: [] };
371
+ }