product-console-companion 0.5.2 → 0.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/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  A local-first BYOS companion for the [Fynch Product Console](../README.md). Reads code from
4
4
  your own machine, so:
5
5
 
6
- - code never leaves your machine — only a small structured verdict (matches/differs + a short
7
- explanation) is ever sent back;
6
+ - code never goes to Fynch's servers — only a small structured verdict (matches/differs + a short
7
+ explanation) is sent back; the locally chosen AI provider receives the text needed to judge it;
8
8
  - there's no server-side truncation on what gets read (`server/drift.ts`'s
9
9
  `MAX_FILE_CHARS`/`MAX_TREE_PATHS` don't apply here at all);
10
10
  - AI spend bills against your own key/subscription, not the workspace's shared/BYOK credential.
@@ -13,7 +13,7 @@ Runs every AI task the hosted Console dispatches. That includes the repo-aware s
13
13
  checks as well as graph reasoning such as impact analysis, reviews, coupling maps, graph Q&A,
14
14
  edge descriptions, and exports. Graph-reasoning tasks receive all of their context from the
15
15
  Console and run connectionless with zero filesystem access. New server-side graph-reasoning task
16
- kinds use the same safe fallback without requiring another companion release. Two backends are
16
+ kinds use the same safe fallback without requiring another companion release. Three backends are
17
17
  available, chosen per connection at `connect` time:
18
18
 
19
19
  - **`api-key`** (default) — no CLI dependency. Calls Anthropic directly with your own API key
@@ -26,6 +26,9 @@ available, chosen per connection at `connect` time:
26
26
  against a disposable, read-only git worktree (`sandbox.ts`) as defense-in-depth on top of
27
27
  `--restricted --tools "Read,Grep,Glob"`, which should already make a write/exec structurally
28
28
  impossible on its own.
29
+ - **`codex`** — uses a ChatGPT subscription through the Codex CLI as a model-only backend. The
30
+ companion still selects and reads files itself; Codex is given only their text and cannot
31
+ explore the checkout.
29
32
 
30
33
  ## Setup
31
34
 
@@ -63,7 +66,8 @@ npx product-console-companion connect --url https://console.fynchconsulting.com
63
66
 
64
67
  This runs your workspace's PRD-only setup AI calls when there is no repo yet. `run` then polls
65
68
  for workspace-level tasks in addition to any per-repo connections configured on this machine.
66
- Pass `--backend cli` to use your Claude subscription for these repo-less tasks too. This mode
69
+ Pass `--backend cli` to use your Claude subscription, or `--backend codex` to use a ChatGPT
70
+ subscription, for these repo-less tasks too. This mode
67
71
  runs with zero filesystem access (stricter than the repo-scoped `cli` backend, because there is
68
72
  nothing to read). PDF-format PRDs cannot be delivered to the `cli` backend yet; use `api-key`,
69
73
  or re-upload the PRD as `.md`, `.txt`, or `.docx`.
@@ -79,6 +83,56 @@ into it first — `npm install -g @anthropic-ai/claude-code`, then run `claude`
79
83
  prompt. `connect` checks `claude --version` up front and fails fast if it's missing. Bills against
80
84
  your Claude subscription, not a separate API key.
81
85
 
86
+ ## ChatGPT subscription through Codex CLI
87
+
88
+ **Requirements.** Install the Codex CLI with `npm install -g @openai/codex`, then log in once to
89
+ the companion's own Codex home. This is separate from your normal Codex login:
90
+
91
+ ```powershell
92
+ New-Item -ItemType Directory -Force "$HOME\.product-console-companion\codex-home" | Out-Null; $env:CODEX_HOME="$HOME\.product-console-companion\codex-home"; codex login; Remove-Item Env:CODEX_HOME
93
+ ```
94
+
95
+ ```bash
96
+ mkdir -p "$HOME/.product-console-companion/codex-home" && CODEX_HOME="$HOME/.product-console-companion/codex-home" codex login
97
+ ```
98
+
99
+ Use the PowerShell command on Windows and the shell command on macOS/Linux. Codex loads the global
100
+ `AGENTS.md` from its home into every call, so the separate login ensures your own instructions do
101
+ not shape Product Console assessments. The folder must exist before `codex login`, and the Windows
102
+ line clears `CODEX_HOME` afterwards so the terminal goes back to your own Codex login.
103
+ `connect --backend codex` checks this login before it saves
104
+ anything, and `run` checks it again before it polls or claims work. A logged-out `codex exec` can
105
+ retry a 401 ten times, so the companion never uses a failed task as a login check.
106
+
107
+ **How it works.** Codex never runs commands and is never given a path to your files. The companion
108
+ picks and reads the relevant files itself, exactly like the API-key backend, and gives Codex their
109
+ text to judge. Codex runs in an empty temporary folder. Like the API-key backend, it sees only the
110
+ files selected for it, up to eight per module; the Claude Code backend can explore further.
111
+
112
+ **What stops it acting.** Every Codex feature that can be switched off is switched off, no Codex
113
+ sandbox is used, and on the supported models every local tool routes through one mechanism that
114
+ refuses when switched off. Codex still lists some tools it cannot use. If it ever tries one, the
115
+ companion stops the call and throws the answer away. This was verified on Windows; the
116
+ companion-side stop applies on every platform.
117
+
118
+ Some prompt material cannot be removed by any tested setting: a skills list and a multi-agent
119
+ role message remain. That role message's own instruction forbids starting sub-agents unless the
120
+ user asks. Your environment variables are kept away from Codex: variables whose names look like
121
+ credentials are removed before Codex starts, and Codex's command environment is restricted too.
122
+
123
+ **No dollar budget enforcement.** Codex reports tokens, not cost, and exposes no budget flag or
124
+ output-token cap. The 10-minute timeout is the only hard bound. The companion logs the returned
125
+ token usage and explicitly marks the task's dollar budget as unenforced.
126
+
127
+ One model is used for every task type because there is no evidence that ChatGPT-plan models map to
128
+ the Claude tiers sent by the server. The default is `gpt-6-astra`. To override it, pass
129
+ `--codex-model gpt-5.6-sol`, `--codex-model gpt-5.6-terra`, or `--codex-model gpt-5.6-luna` to
130
+ `connect`. Other Codex models are refused because they were not verified to refuse tool use when
131
+ their tools are switched off. The server's requested reasoning effort still passes through.
132
+
133
+ PDF PRDs are not supported on this backend. Re-upload one as `.md`, `.txt`, or `.docx`, or use the
134
+ API-key backend.
135
+
82
136
  Once it's running:
83
137
 
84
138
  ```bash
@@ -1,3 +1,16 @@
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';
1
14
  import { readFile } from 'node:fs/promises';
2
15
  import { join } from 'node:path';
3
16
  import { buildLocalDesignContext } from './design-context.js';
@@ -9,7 +22,40 @@ const MAX_CANDIDATE_FILES = 8;
9
22
  * this backend is that what IS read is never truncated; this only guards against an
10
23
  * accidentally-selected generated/binary/data file blowing up the prompt. */
11
24
  const MAX_FILE_BYTES = 512 * 1024;
12
- const fileSelectSchema = {
25
+ /** Structured model-only call used by the investigation reader. The reader, not this call and
26
+ * not the model, owns every repository read and evidence id. */
27
+ export async function runStructuredModelCall(client, request) {
28
+ const stream = client.messages.stream({
29
+ model: request.model,
30
+ max_tokens: request.maxTokens,
31
+ thinking: { type: 'adaptive' },
32
+ output_config: {
33
+ effort: request.effort,
34
+ format: { type: 'json_schema', schema: request.schema },
35
+ },
36
+ system: request.system,
37
+ messages: [{ role: 'user', content: request.prompt }],
38
+ });
39
+ let message;
40
+ try {
41
+ message = await stream.finalMessage();
42
+ }
43
+ catch (error) {
44
+ if (error instanceof Anthropic.RateLimitError) {
45
+ throw new Error('budget_exhausted: Anthropic API rate limit (HTTP 429).', { cause: error });
46
+ }
47
+ throw error;
48
+ }
49
+ if (message.stop_reason === 'refusal')
50
+ throw new Error('The model declined the structured investigation call.');
51
+ if (message.stop_reason === 'max_tokens')
52
+ throw new Error('budget_exhausted: the structured investigation call reached its output-token limit.');
53
+ const text = message.content.find((block) => block.type === 'text');
54
+ if (!text || text.type !== 'text')
55
+ throw new Error('The structured investigation call returned no text.');
56
+ return JSON.parse(text.text);
57
+ }
58
+ export const fileSelectSchema = {
13
59
  type: 'object',
14
60
  additionalProperties: false,
15
61
  required: ['paths'],
@@ -39,7 +85,7 @@ async function readLocalFile(repoPath, relPath) {
39
85
  * exact schema the task shipped with (task.payload.spec.schema), `filesRead` for the UI's
40
86
  * provenance note. Throws on anything that should surface as a task error (caller reports it
41
87
  * via POST .../error). */
42
- export async function runVerifyModuleTask(client, task, repoPath) {
88
+ export async function runVerifyModuleTask(callModel, task, repoPath) {
43
89
  const payload = task.payload;
44
90
  const spec = payload.spec;
45
91
  const allFiles = await listRepoFiles(repoPath);
@@ -58,20 +104,18 @@ ${allFiles.join('\n')}
58
104
  First, list the file paths (verbatim from the tree above, up to ${MAX_CANDIDATE_FILES}) most
59
105
  likely to implement this module's behaviour. Prefer application source over tests, config, and
60
106
  generated files. Empty list if nothing plausibly implements it.`;
61
- const selectStream = client.messages.stream({
107
+ const selectMsg = await callModel({
108
+ purpose: 'verify_module:file_select',
62
109
  model: spec.model,
63
- max_tokens: 2000,
64
- thinking: { type: 'adaptive' },
65
- output_config: { effort: 'low', format: { type: 'json_schema', schema: fileSelectSchema } },
110
+ maxTokens: 2000,
111
+ effort: 'low',
66
112
  system: spec.system,
67
- messages: [{ role: 'user', content: selectPrompt }],
113
+ stableText: '',
114
+ text: selectPrompt,
115
+ schema: fileSelectSchema,
68
116
  });
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
117
  const known = new Set(allFiles);
74
- const chosen = JSON.parse(selectText.text).paths.filter((p) => known.has(p)).slice(0, MAX_CANDIDATE_FILES);
118
+ const chosen = JSON.parse(selectMsg.text).paths.filter((p) => known.has(p)).slice(0, MAX_CANDIDATE_FILES);
75
119
  if (chosen.length === 0) {
76
120
  return {
77
121
  filesRead: [],
@@ -91,30 +135,17 @@ generated files. Empty list if nothing plausibly implements it.`;
91
135
  // Pass 2: does the code satisfy the spec? spec.system/spec.volatileText/spec.schema are sent
92
136
  // by the server unchanged — this is the exact same prompt+schema the server-execution path
93
137
  // 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({
138
+ const verdictMsg = await callModel({
139
+ purpose: 'verify_module:judge',
102
140
  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 },
141
+ maxTokens: spec.maxTokens,
142
+ effort: spec.effort,
108
143
  system: spec.system,
109
- messages: [{ role: 'user', content }],
144
+ stableText: spec.stableText,
145
+ 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')}`,
146
+ schema: spec.schema,
110
147
  });
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) };
148
+ return { result: JSON.parse(verdictMsg.text), filesRead: files.map((f) => f.path) };
118
149
  }
119
150
  /* ------------------------------------------------------------------ generate_spec_batch */
120
151
  /** Matches verify_module's own MAX_CANDIDATE_FILES — tried lower (4) first on the theory that a
@@ -126,7 +157,7 @@ generated files. Empty list if nothing plausibly implements it.`;
126
157
  * beats the server path's own FILES_PER_MODULE=2 (bootstrap.ts) either way; this raises the
127
158
  * ceiling for the genuinely multi-file case rather than assuming it wouldn't come up. */
128
159
  const MAX_FILES_PER_MODULE_IN_BATCH = 8;
129
- const batchFileSelectSchema = {
160
+ export const batchFileSelectSchema = {
130
161
  type: 'object',
131
162
  additionalProperties: false,
132
163
  required: ['selections'],
@@ -156,31 +187,23 @@ const batchFileSelectSchema = {
156
187
  * the whole specs array in pass 2. `spec.volatileText` already carries the per-module id/lane/
157
188
  * purpose breakdown and the "find files yourself" instruction (buildSpecBatchVolatile,
158
189
  * 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) {
190
+ export async function runGenerateSpecBatchTask(callModel, task, repoPath) {
160
191
  const payload = task.payload;
161
192
  const spec = payload.spec;
162
193
  const moduleIds = payload.moduleIds;
163
194
  if (payload.connectionless) {
164
195
  // The PRD is already in spec.stableText (stableTextForLocalDispatch); there is nothing local to read.
165
- const content = [];
166
- if (spec.stableText)
167
- content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
168
- content.push({ type: 'text', text: spec.volatileText });
169
- const writeStream = client.messages.stream({
196
+ const writeMsg = await callModel({
197
+ purpose: 'generate_spec_batch:write',
170
198
  model: spec.model,
171
- max_tokens: spec.maxTokens,
172
- thinking: { type: 'adaptive' },
173
- output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
199
+ maxTokens: spec.maxTokens,
200
+ effort: spec.effort,
174
201
  system: spec.system,
175
- messages: [{ role: 'user', content }],
202
+ stableText: spec.stableText,
203
+ text: spec.volatileText,
204
+ schema: spec.schema,
176
205
  });
177
- const writeMsg = await writeStream.finalMessage();
178
- if (writeMsg.stop_reason === 'refusal')
179
- throw new Error('The model declined to write specs for this batch.');
180
- const writeText = writeMsg.content.find((b) => b.type === 'text');
181
- if (!writeText || writeText.type !== 'text')
182
- throw new Error('No specs returned.');
183
- return { result: JSON.parse(writeText.text), filesRead: [] };
206
+ return { result: JSON.parse(writeMsg.text), filesRead: [] };
184
207
  }
185
208
  const allFiles = await listRepoFiles(repoPath);
186
209
  console.log(` scanning ${allFiles.length} files in ${repoPath} for ${moduleIds.length} module(s)…`);
@@ -196,20 +219,18 @@ ${allFiles.join('\n')}
196
219
  For EACH module above (by id: ${moduleIds.join(', ')}), list up to ${MAX_FILES_PER_MODULE_IN_BATCH}
197
220
  file paths (verbatim from the tree above) most likely to implement it. One entry per module id,
198
221
  in the same order as given.`;
199
- const selectStream = client.messages.stream({
222
+ const selectMsg = await callModel({
223
+ purpose: 'generate_spec_batch:file_select',
200
224
  model: spec.model,
201
- max_tokens: 3000,
202
- thinking: { type: 'adaptive' },
203
- output_config: { effort: 'low', format: { type: 'json_schema', schema: batchFileSelectSchema } },
225
+ maxTokens: 3000,
226
+ effort: 'low',
204
227
  system: spec.system,
205
- messages: [{ role: 'user', content: selectPrompt }],
228
+ stableText: '',
229
+ text: selectPrompt,
230
+ schema: batchFileSelectSchema,
206
231
  });
207
- const selectMsg = await selectStream.finalMessage();
208
- const selectText = selectMsg.content.find((b) => b.type === 'text');
209
- if (!selectText || selectText.type !== 'text')
210
- throw new Error('Could not pick files for this batch (no text in the file-select response).');
211
232
  const known = new Set(allFiles);
212
- const selections = JSON.parse(selectText.text).selections;
233
+ const selections = JSON.parse(selectMsg.text).selections;
213
234
  const uniquePaths = [...new Set(selections.flatMap((s) => s.paths.filter((p) => known.has(p)).slice(0, MAX_FILES_PER_MODULE_IN_BATCH)))];
214
235
  const fileMap = new Map();
215
236
  await Promise.all(uniquePaths.map(async (p) => {
@@ -225,25 +246,17 @@ in the same order as given.`;
225
246
  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)'}`;
226
247
  })
227
248
  .join('\n\n');
228
- const content = [];
229
- if (spec.stableText)
230
- content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
231
- content.push({ type: 'text', text: `${spec.volatileText}\n\n# Related files, as found in the local checkout\n\n${filesSection}` });
232
- const writeStream = client.messages.stream({
249
+ const writeMsg = await callModel({
250
+ purpose: 'generate_spec_batch:write',
233
251
  model: spec.model,
234
- max_tokens: spec.maxTokens,
235
- thinking: { type: 'adaptive' },
236
- output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
252
+ maxTokens: spec.maxTokens,
253
+ effort: spec.effort,
237
254
  system: spec.system,
238
- messages: [{ role: 'user', content }],
255
+ stableText: spec.stableText,
256
+ text: `${spec.volatileText}\n\n# Related files, as found in the local checkout\n\n${filesSection}`,
257
+ schema: spec.schema,
239
258
  });
240
- const writeMsg = await writeStream.finalMessage();
241
- if (writeMsg.stop_reason === 'refusal')
242
- throw new Error('The model declined to write specs for this batch.');
243
- const writeText = writeMsg.content.find((b) => b.type === 'text');
244
- if (!writeText || writeText.type !== 'text')
245
- throw new Error('No specs returned.');
246
- return { result: JSON.parse(writeText.text), filesRead: [...fileMap.keys()] };
259
+ return { result: JSON.parse(writeMsg.text), filesRead: [...fileMap.keys()] };
247
260
  }
248
261
  /* ---------------------------------------------------- propose_skeleton / propose_new_capabilities */
249
262
  /**
@@ -258,7 +271,7 @@ in the same order as given.`;
258
271
  * server/index.ts's completeAiJob payloads for /api/setup/skeleton and
259
272
  * /api/module-suggestions/scan — neither includes it), so there's nothing meaningful to report.
260
273
  */
261
- async function runShallowRepoTask(client, spec, repoPath, connectionless = false) {
274
+ async function runShallowRepoTask(callModel, purpose, spec, repoPath, connectionless = false) {
262
275
  let repoSection = '';
263
276
  if (!connectionless) {
264
277
  const repoCtx = await buildLocalRepoContext(repoPath);
@@ -279,45 +292,35 @@ ${repoCtx.changelog ?? '(none found)'}
279
292
  ${repoCtx.packageJson ?? '(none found)'}
280
293
  `.trim();
281
294
  }
282
- const content = [];
283
- if (spec.stableDocumentBase64) {
284
- content.push({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: spec.stableDocumentBase64 } });
285
- }
286
- if (spec.stableText)
287
- content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
288
- content.push({ type: 'text', text: connectionless ? spec.volatileText : `${spec.volatileText}\n\n${repoSection}` });
289
- const stream = client.messages.stream({
295
+ const message = await callModel({
296
+ purpose,
290
297
  model: spec.model,
291
- max_tokens: spec.maxTokens,
292
- thinking: { type: 'adaptive' },
293
- output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
298
+ maxTokens: spec.maxTokens,
299
+ effort: spec.effort,
294
300
  system: spec.system,
295
- messages: [{ role: 'user', content }],
301
+ stableText: spec.stableText,
302
+ text: connectionless ? spec.volatileText : `${spec.volatileText}\n\n${repoSection}`,
303
+ schema: spec.schema,
304
+ documentBase64: spec.stableDocumentBase64,
296
305
  });
297
- const message = await stream.finalMessage();
298
- if (message.stop_reason === 'refusal')
299
- throw new Error('The model declined this request.');
300
- const text = message.content.find((b) => b.type === 'text');
301
- if (!text || text.type !== 'text')
302
- throw new Error('No result returned.');
303
- return { result: spec.schema ? JSON.parse(text.text) : text.text, filesRead: [] };
306
+ return { result: spec.schema ? JSON.parse(message.text) : message.text, filesRead: [] };
304
307
  }
305
308
  /** Forward-compatible fallback for server-created graph/spec reasoning tasks. The server sends
306
309
  * the complete self-describing spec, so these calls never need or receive filesystem context. */
307
- export async function runGenericTask(client, task, repoPath) {
310
+ export async function runGenericTask(callModel, task, repoPath) {
308
311
  const spec = task.payload?.spec;
309
312
  if (!spec || typeof spec !== 'object' || Array.isArray(spec)) {
310
313
  throw new Error(`Cannot run task kind "${task.kind}": payload.spec is missing or not an object.`);
311
314
  }
312
- return runShallowRepoTask(client, spec, repoPath, true);
315
+ return runShallowRepoTask(callModel, 'generic', spec, repoPath, true);
313
316
  }
314
- export async function runProposeSkeletonTask(client, task, repoPath) {
317
+ export async function runProposeSkeletonTask(callModel, task, repoPath) {
315
318
  const payload = task.payload;
316
- return runShallowRepoTask(client, payload.spec, repoPath, payload.connectionless);
319
+ return runShallowRepoTask(callModel, 'propose_skeleton', payload.spec, repoPath, payload.connectionless);
317
320
  }
318
- export async function runProposeNewCapabilitiesTask(client, task, repoPath) {
321
+ export async function runProposeNewCapabilitiesTask(callModel, task, repoPath) {
319
322
  const payload = task.payload;
320
- return runShallowRepoTask(client, payload.spec, repoPath);
323
+ return runShallowRepoTask(callModel, 'propose_new_capabilities', payload.spec, repoPath);
321
324
  }
322
325
  /* --------------------------------------------------------------------- design_prompt */
323
326
  /**
@@ -329,33 +332,22 @@ export async function runProposeNewCapabilitiesTask(client, task, repoPath) {
329
332
  * generateDesignPrompt — the local-dispatch branch decides which wording to send based on the
330
333
  * same `direction` the server path already respects, before this ever reaches the companion).
331
334
  */
332
- export async function runDesignPromptTask(client, task, repoPath) {
335
+ export async function runDesignPromptTask(callModel, task, repoPath) {
333
336
  const payload = task.payload;
334
337
  const spec = payload.spec;
335
338
  const designContext = await buildLocalDesignContext(repoPath);
336
339
  console.log(` design signal: ${designContext ? 'found' : 'none found'}`);
337
- const content = [];
338
- if (spec.stableText)
339
- content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
340
- content.push({
341
- type: 'text',
342
- text: designContext ? `${spec.volatileText}\n\n# Design signal found in your local checkout\n\n${designContext}` : spec.volatileText,
343
- });
344
- const stream = client.messages.stream({
340
+ const message = await callModel({
341
+ purpose: 'design_prompt',
345
342
  model: spec.model,
346
- max_tokens: spec.maxTokens,
347
- thinking: { type: 'adaptive' },
348
- output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
343
+ maxTokens: spec.maxTokens,
344
+ effort: spec.effort,
349
345
  system: spec.system,
350
- messages: [{ role: 'user', content }],
346
+ stableText: spec.stableText,
347
+ text: designContext ? `${spec.volatileText}\n\n# Design signal found in your local checkout\n\n${designContext}` : spec.volatileText,
348
+ schema: spec.schema,
351
349
  });
352
- const message = await stream.finalMessage();
353
- if (message.stop_reason === 'refusal')
354
- throw new Error('The model declined to write a design prompt.');
355
- const text = message.content.find((b) => b.type === 'text');
356
- if (!text || text.type !== 'text')
357
- throw new Error('No design prompt returned.');
358
- return { result: text.text, filesRead: [] }; // filesRead unused for this kind — see propose_skeleton/propose_new_capabilities's own comment
350
+ return { result: message.text, filesRead: [] }; // filesRead unused for this kind — see propose_skeleton/propose_new_capabilities's own comment
359
351
  }
360
352
  /* --------------------------------------------------------------------- analyse_shipment */
361
353
  /**
@@ -369,7 +361,7 @@ export async function runDesignPromptTask(client, task, repoPath) {
369
361
  * error via POST .../error, which surfaces to checkConnection as one caught, per-commit failure
370
362
  * (see server/poller.ts's own comment), not a crash that would wedge the rest of that check.
371
363
  */
372
- export async function runAnalyseShipmentTask(client, task, repoPath) {
364
+ export async function runAnalyseShipmentTask(callModel, task, repoPath) {
373
365
  const payload = task.payload;
374
366
  const prep = await prepareShipmentAnalysis(repoPath, payload.sha);
375
367
  if (prep.outcome === 'sha_not_local') {
@@ -381,25 +373,17 @@ export async function runAnalyseShipmentTask(client, task, repoPath) {
381
373
  }
382
374
  console.log(` read ${payload.sha.slice(0, 7)}'s diff locally (${prep.diffText.length} chars, ${prep.changedPaths.length} file(s) changed)`);
383
375
  const spec = appendDiffToVolatile(payload.spec, prep.diffText, prep.changedPaths);
384
- const content = [];
385
- if (spec.stableText)
386
- content.push({ type: 'text', text: spec.stableText, cache_control: { type: 'ephemeral' } });
387
- content.push({ type: 'text', text: spec.volatileText });
388
- const stream = client.messages.stream({
376
+ const message = await callModel({
377
+ purpose: 'analyse_shipment',
389
378
  model: spec.model,
390
- max_tokens: spec.maxTokens,
391
- thinking: { type: 'adaptive' },
392
- output_config: spec.schema ? { effort: spec.effort, format: { type: 'json_schema', schema: spec.schema } } : { effort: spec.effort },
379
+ maxTokens: spec.maxTokens,
380
+ effort: spec.effort,
393
381
  system: spec.system,
394
- messages: [{ role: 'user', content }],
382
+ stableText: spec.stableText,
383
+ text: spec.volatileText,
384
+ schema: spec.schema,
395
385
  });
396
- const message = await stream.finalMessage();
397
- if (message.stop_reason === 'refusal')
398
- throw new Error('The model declined to analyse this shipment.');
399
- const text = message.content.find((b) => b.type === 'text');
400
- if (!text || text.type !== 'text')
401
- throw new Error('No drift analysis returned.');
402
386
  // filesRead unused for this kind, same as propose_skeleton/propose_new_capabilities above —
403
387
  // dispatchAndAwaitLocalTask's caller (poller.ts) only consumes the parsed result, not filesRead.
404
- return { result: JSON.parse(text.text), filesRead: [] };
388
+ return { result: JSON.parse(message.text), filesRead: [] };
405
389
  }