codecartographer-pi 0.25.0 → 0.26.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.
Files changed (50) hide show
  1. package/.codecarto/broadside/SKILL.md +4 -1
  2. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  3. package/README.md +2 -2
  4. package/dist/core/broadside/client.d.ts +56 -0
  5. package/dist/core/broadside/client.js +200 -0
  6. package/dist/core/broadside/collect.d.ts +68 -0
  7. package/dist/core/broadside/collect.js +676 -0
  8. package/dist/core/broadside/constants.d.ts +51 -0
  9. package/dist/core/broadside/constants.js +74 -0
  10. package/dist/core/broadside/lenses.d.ts +31 -0
  11. package/dist/core/broadside/lenses.js +312 -0
  12. package/dist/core/broadside/models.d.ts +46 -0
  13. package/dist/core/broadside/models.js +321 -0
  14. package/dist/core/broadside/render.d.ts +20 -0
  15. package/dist/core/broadside/render.js +285 -0
  16. package/dist/core/broadside/repo.d.ts +58 -0
  17. package/dist/core/broadside/repo.js +592 -0
  18. package/dist/core/broadside/requests.d.ts +23 -0
  19. package/dist/core/broadside/requests.js +71 -0
  20. package/dist/core/broadside/results.d.ts +36 -0
  21. package/dist/core/broadside/results.js +163 -0
  22. package/dist/core/broadside/schemas.d.ts +2 -0
  23. package/dist/core/broadside/schemas.js +342 -0
  24. package/dist/core/broadside/state.d.ts +99 -0
  25. package/dist/core/broadside/state.js +384 -0
  26. package/dist/core/broadside/submit.d.ts +30 -0
  27. package/dist/core/broadside/submit.js +350 -0
  28. package/dist/core/broadside/types.d.ts +491 -0
  29. package/dist/core/broadside/types.js +107 -0
  30. package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
  31. package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
  32. package/dist/core/broadside.d.ts +14 -952
  33. package/dist/core/broadside.js +25 -3726
  34. package/dist/core/completion.js +91 -72
  35. package/dist/core/dashboard-writer.js +9 -1
  36. package/dist/core/index.d.ts +0 -1
  37. package/dist/core/index.js +0 -1
  38. package/dist/core/library.d.ts +24 -1
  39. package/dist/core/library.js +46 -15
  40. package/dist/core/orchestrator-config.js +22 -8
  41. package/dist/core/status.d.ts +42 -23
  42. package/dist/core/status.js +163 -137
  43. package/dist/core/workspace.d.ts +2 -0
  44. package/dist/core/workspace.js +49 -25
  45. package/dist/core/yaml.js +9 -3
  46. package/dist/extensions/codecarto/auto-runner.js +41 -23
  47. package/dist/extensions/codecarto/index.js +9 -4
  48. package/dist/extensions/codecarto/phase-compaction.js +6 -2
  49. package/dist/mcp-server/server.js +15 -4
  50. package/package.json +1 -1
@@ -0,0 +1,99 @@
1
+ import { type BroadsideConfig, type BroadsideRun, type BroadsideRunSlot, type BroadsideStateFile } from "./types.ts";
2
+ export declare function broadsideDirFor(cwd: string): string;
3
+ /**
4
+ * Read the Broad-Side reading guide.
5
+ *
6
+ * It is deliberately not a post-pipeline skill under `.codecarto/skills/`: a
7
+ * scout run is read *before* or *during* the interactive pipeline, and the
8
+ * post-pipeline machinery gates on a completed run and wraps its prompt in
9
+ * post-pipeline framing that would be false here. It is also readable on a
10
+ * repository that has scout state and no workspace at all, which is why this
11
+ * falls back to the packaged copy.
12
+ *
13
+ * @param cwd - Absolute path to the target repository.
14
+ * @returns the skill text and the path it came from.
15
+ * @throws when neither the workspace copy nor the packaged copy exists.
16
+ */
17
+ export declare function readBroadsideSkill(cwd: string): Promise<{
18
+ path: string;
19
+ content: string;
20
+ }>;
21
+ export declare function defaultBroadsideState(): BroadsideStateFile;
22
+ export declare function loadBroadsideState(broadsideDir: string): Promise<BroadsideStateFile>;
23
+ /**
24
+ * Overwrite `state.json` wholesale with `state`.
25
+ *
26
+ * Prefer {@link persistBroadsideRun} anywhere a live operation is recording its
27
+ * own progress — this entry point replaces the file, so any run a concurrent
28
+ * process recorded in the meantime is erased. It remains the right call for
29
+ * seeding a fresh workspace and for test fixtures, where "make the file exactly
30
+ * this" is the intent.
31
+ */
32
+ export declare function saveBroadsideState(broadsideDir: string, state: BroadsideStateFile): Promise<void>;
33
+ /**
34
+ * Read-modify-write `state.json` under a lock.
35
+ *
36
+ * The lock is held only for the read-modify-write, never for the surrounding
37
+ * operation: a `collect` can poll for the better part of an hour, and holding
38
+ * the lock across that would push every concurrent caller past the 5s lock
39
+ * timeout.
40
+ */
41
+ export declare function updateBroadsideStateAtomically(broadsideDir: string, mutate: (state: BroadsideStateFile) => void | Promise<void>): Promise<BroadsideStateFile>;
42
+ /**
43
+ * Record one run's current shape, merged into whatever is on disk *now*.
44
+ *
45
+ * Broad-Side operations are long-lived and hold their state in memory while
46
+ * they poll. Writing that snapshot back wholesale silently erased any run a
47
+ * concurrent operation had recorded since it was loaded, orphaning that run's
48
+ * paid results on disk — present as files, invisible to `list`, and unreachable
49
+ * by `collect`, which finds its run by position in `state.runs`. Observed live:
50
+ * a submit at 23:35 was erased by a collect that had loaded state before it and
51
+ * wrote back at 00:08.
52
+ *
53
+ * Merging by run id also self-heals: a run erased by an older writer is
54
+ * restored the next time its own operation checkpoints.
55
+ */
56
+ export declare function persistBroadsideRun(broadsideDir: string, run: BroadsideRun): Promise<BroadsideStateFile>;
57
+ /**
58
+ * Record a collect's view of its run, keeping whatever is further along on
59
+ * disk (#322).
60
+ *
61
+ * Two collects on one run each hold the run in memory and each used to write
62
+ * the whole thing back, so the last writer replaced the other's post-pass
63
+ * entries with its own — and both had submitted their own post-passes, since
64
+ * each decided from the copy it loaded at entry. This writer merges slot by
65
+ * slot: a post-pass or retry entry that is further along on disk (claimed
66
+ * over pending, submitted over claimed, settled over submitted) wins and is
67
+ * copied into `run`, so the caller reports what is true; a lens entry never
68
+ * goes backwards from terminal to polling. A tie keeps this collect's copy,
69
+ * so the collect that settled a pass records its cost. Submitting is guarded
70
+ * separately by {@link claimRunSlot}.
71
+ */
72
+ export declare function persistBroadsideRunMerging(broadsideDir: string, run: BroadsideRun): Promise<BroadsideStateFile>;
73
+ /**
74
+ * Claim one spending slot of a run for this collect (#322).
75
+ *
76
+ * Read-modify-write under the state lock: if the slot on disk is still
77
+ * unclaimed (`pending`, or absent for the retry), it is marked `submitted`
78
+ * with no batch id *before* any network call and `true` comes back — this
79
+ * collect owns it and may submit. Otherwise another collect got there first:
80
+ * its entry is copied into `run` and `false` comes back. An adopted entry
81
+ * with a batch id can be polled (polling is idempotent); one without an id
82
+ * is a claim whose owner has not recorded the id yet, and is reported as in
83
+ * flight elsewhere.
84
+ */
85
+ export declare function claimRunSlot(broadsideDir: string, run: BroadsideRun, slot: BroadsideRunSlot): Promise<boolean>;
86
+ /**
87
+ * Put a run's settled post-passes back to `pending` on disk so the next
88
+ * claim re-runs them (#338). A pass another collect has in flight is left
89
+ * alone — its result is still coming. The replaced results' cost moves to
90
+ * `retiredCost`, so the run's total keeps counting money it spent. Returns
91
+ * the passes that were reset, in the order they will be re-run.
92
+ */
93
+ export declare function resetRunPostPasses(broadsideDir: string, run: BroadsideRun, wanted: {
94
+ synthesis: boolean;
95
+ triage: boolean;
96
+ }): Promise<Array<"synthesis" | "triage">>;
97
+ export declare function loadBroadsideConfig(broadsideDir: string): Promise<BroadsideConfig>;
98
+ /** The shipped defaults: what an absent config.yaml means. */
99
+ export declare function defaultBroadsideConfig(): BroadsideConfig;
@@ -0,0 +1,384 @@
1
+ // The .codecarto/broadside/ state file (runs, claims, merging persists) and config.yaml loading.
2
+ //
3
+ // Split out of core/broadside.ts (#339); the barrel there re-exports every
4
+ // name, so `core/index.ts` and the tests see one module as before.
5
+ import { createHash } from "node:crypto";
6
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { atomicWriteFile, pathExists } from "../utils.js";
9
+ import { acquireLock } from "../status.js";
10
+ import { loadYamlFile } from "../yaml.js";
11
+ import { packagedWorkspaceDir } from "../workspace.js";
12
+ import { BROADSIDE_CONFIG_FILE, BROADSIDE_TERMINAL_ENTRY_STATUSES, BROADSIDE_DEFAULT_MAX_COST, BROADSIDE_DIR, BROADSIDE_LENS_IDS, BROADSIDE_MODEL, BROADSIDE_STATE_FILE, BROADSIDE_STATE_SCHEMA_VERSION } from "./constants.js";
13
+ import { BroadsideConfigError, BroadsideStateError } from "./types.js";
14
+ // ---------- state & config ----------
15
+ export function broadsideDirFor(cwd) {
16
+ return join(cwd, ".codecarto", BROADSIDE_DIR);
17
+ }
18
+ /**
19
+ * Read the Broad-Side reading guide.
20
+ *
21
+ * It is deliberately not a post-pipeline skill under `.codecarto/skills/`: a
22
+ * scout run is read *before* or *during* the interactive pipeline, and the
23
+ * post-pipeline machinery gates on a completed run and wraps its prompt in
24
+ * post-pipeline framing that would be false here. It is also readable on a
25
+ * repository that has scout state and no workspace at all, which is why this
26
+ * falls back to the packaged copy.
27
+ *
28
+ * @param cwd - Absolute path to the target repository.
29
+ * @returns the skill text and the path it came from.
30
+ * @throws when neither the workspace copy nor the packaged copy exists.
31
+ */
32
+ export async function readBroadsideSkill(cwd) {
33
+ const candidates = [
34
+ join(broadsideDirFor(cwd), "SKILL.md"),
35
+ join(packagedWorkspaceDir, BROADSIDE_DIR, "SKILL.md"),
36
+ ];
37
+ for (const path of candidates) {
38
+ if (await pathExists(path))
39
+ return { path, content: await readFile(path, "utf8") };
40
+ }
41
+ throw new Error(`Broad-Side skill not found at ${candidates.join(" or ")}. Reinstall codecartographer-pi.`);
42
+ }
43
+ export function defaultBroadsideState() {
44
+ return { schema_version: BROADSIDE_STATE_SCHEMA_VERSION, runs: [] };
45
+ }
46
+ export async function loadBroadsideState(broadsideDir) {
47
+ const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
48
+ if (!(await pathExists(statePath)))
49
+ return defaultBroadsideState();
50
+ const text = await readFile(statePath, "utf8");
51
+ let raw;
52
+ try {
53
+ raw = JSON.parse(text);
54
+ }
55
+ catch (error) {
56
+ throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
57
+ }
58
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs)) {
59
+ throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), "is not a state file (expected an object with a runs array)");
60
+ }
61
+ return raw;
62
+ }
63
+ /**
64
+ * Copy an unreadable state file to `state.json.corrupt-<hash>` beside it,
65
+ * named by content so repeated loads do not multiply copies. Returns the
66
+ * copy's path (the existing one, when the same content was preserved before).
67
+ */
68
+ async function preserveCorruptState(statePath, text) {
69
+ const digest = createHash("sha1").update(text).digest("hex").slice(0, 8);
70
+ const backupPath = `${statePath}.corrupt-${digest}`;
71
+ if (!(await pathExists(backupPath)))
72
+ await writeFile(backupPath, text, "utf8");
73
+ return backupPath;
74
+ }
75
+ /**
76
+ * Overwrite `state.json` wholesale with `state`.
77
+ *
78
+ * Prefer {@link persistBroadsideRun} anywhere a live operation is recording its
79
+ * own progress — this entry point replaces the file, so any run a concurrent
80
+ * process recorded in the meantime is erased. It remains the right call for
81
+ * seeding a fresh workspace and for test fixtures, where "make the file exactly
82
+ * this" is the intent.
83
+ */
84
+ export async function saveBroadsideState(broadsideDir, state) {
85
+ await mkdir(broadsideDir, { recursive: true });
86
+ const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
87
+ const lock = await acquireLock(`${statePath}.lock`);
88
+ try {
89
+ await writeBroadsideStateFile(statePath, state);
90
+ }
91
+ finally {
92
+ await lock.release();
93
+ }
94
+ }
95
+ /** Serialize through a temp file so a crash mid-write cannot truncate state.json. */
96
+ async function writeBroadsideStateFile(statePath, state) {
97
+ await atomicWriteFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
98
+ }
99
+ /**
100
+ * Read-modify-write `state.json` under a lock.
101
+ *
102
+ * The lock is held only for the read-modify-write, never for the surrounding
103
+ * operation: a `collect` can poll for the better part of an hour, and holding
104
+ * the lock across that would push every concurrent caller past the 5s lock
105
+ * timeout.
106
+ */
107
+ export async function updateBroadsideStateAtomically(broadsideDir, mutate) {
108
+ await mkdir(broadsideDir, { recursive: true });
109
+ const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
110
+ const lock = await acquireLock(`${statePath}.lock`);
111
+ try {
112
+ const state = await loadBroadsideState(broadsideDir);
113
+ await mutate(state);
114
+ await writeBroadsideStateFile(statePath, state);
115
+ return state;
116
+ }
117
+ finally {
118
+ await lock.release();
119
+ }
120
+ }
121
+ /**
122
+ * Record one run's current shape, merged into whatever is on disk *now*.
123
+ *
124
+ * Broad-Side operations are long-lived and hold their state in memory while
125
+ * they poll. Writing that snapshot back wholesale silently erased any run a
126
+ * concurrent operation had recorded since it was loaded, orphaning that run's
127
+ * paid results on disk — present as files, invisible to `list`, and unreachable
128
+ * by `collect`, which finds its run by position in `state.runs`. Observed live:
129
+ * a submit at 23:35 was erased by a collect that had loaded state before it and
130
+ * wrote back at 00:08.
131
+ *
132
+ * Merging by run id also self-heals: a run erased by an older writer is
133
+ * restored the next time its own operation checkpoints.
134
+ */
135
+ export async function persistBroadsideRun(broadsideDir, run) {
136
+ return updateBroadsideStateAtomically(broadsideDir, (state) => {
137
+ const index = state.runs.findIndex((candidate) => candidate.id === run.id);
138
+ if (index === -1)
139
+ state.runs.push(run);
140
+ else
141
+ state.runs[index] = run;
142
+ });
143
+ }
144
+ /** Where a lens batch entry stands, for keeping the more advanced of two. */
145
+ function batchEntryRank(entry) {
146
+ if (!entry)
147
+ return -1;
148
+ if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status))
149
+ return 2;
150
+ if (entry.batchId)
151
+ return 1;
152
+ return 0;
153
+ }
154
+ /** Where a post-pass entry stands: unclaimed, claimed, submitted, settled. */
155
+ function passEntryRank(entry) {
156
+ if (!entry || entry.status === "pending")
157
+ return 0;
158
+ if (entry.status === "submitted")
159
+ return entry.batchId ? 2 : 1;
160
+ return 3;
161
+ }
162
+ /** Where the retry pass stands: absent, claimed, submitted, settled. */
163
+ function retryEntryRank(entry) {
164
+ if (!entry)
165
+ return 0;
166
+ if (entry.status === "submitted")
167
+ return entry.batches.length > 0 ? 2 : 1;
168
+ return 3;
169
+ }
170
+ /**
171
+ * Record a collect's view of its run, keeping whatever is further along on
172
+ * disk (#322).
173
+ *
174
+ * Two collects on one run each hold the run in memory and each used to write
175
+ * the whole thing back, so the last writer replaced the other's post-pass
176
+ * entries with its own — and both had submitted their own post-passes, since
177
+ * each decided from the copy it loaded at entry. This writer merges slot by
178
+ * slot: a post-pass or retry entry that is further along on disk (claimed
179
+ * over pending, submitted over claimed, settled over submitted) wins and is
180
+ * copied into `run`, so the caller reports what is true; a lens entry never
181
+ * goes backwards from terminal to polling. A tie keeps this collect's copy,
182
+ * so the collect that settled a pass records its cost. Submitting is guarded
183
+ * separately by {@link claimRunSlot}.
184
+ */
185
+ export async function persistBroadsideRunMerging(broadsideDir, run) {
186
+ return updateBroadsideStateAtomically(broadsideDir, (state) => {
187
+ const index = state.runs.findIndex((candidate) => candidate.id === run.id);
188
+ const onDisk = index === -1 ? undefined : state.runs[index];
189
+ if (onDisk) {
190
+ if (passEntryRank(onDisk.synthesis) > passEntryRank(run.synthesis))
191
+ run.synthesis = onDisk.synthesis;
192
+ if (passEntryRank(onDisk.triage) > passEntryRank(run.triage))
193
+ run.triage = onDisk.triage;
194
+ if (retryEntryRank(onDisk.retry) > retryEntryRank(run.retry))
195
+ run.retry = onDisk.retry;
196
+ // A verification pass another process recorded is never dropped by
197
+ // a collect that never knew about it; a newer pass replaces an older.
198
+ if (onDisk.verify && (!run.verify || onDisk.verify.at > run.verify.at))
199
+ run.verify = onDisk.verify;
200
+ for (const [lensId, theirs] of Object.entries(onDisk.batches)) {
201
+ if (theirs && batchEntryRank(theirs) > batchEntryRank(run.batches[lensId]))
202
+ run.batches[lensId] = theirs;
203
+ }
204
+ }
205
+ if (index === -1)
206
+ state.runs.push(run);
207
+ else
208
+ state.runs[index] = run;
209
+ });
210
+ }
211
+ /**
212
+ * Claim one spending slot of a run for this collect (#322).
213
+ *
214
+ * Read-modify-write under the state lock: if the slot on disk is still
215
+ * unclaimed (`pending`, or absent for the retry), it is marked `submitted`
216
+ * with no batch id *before* any network call and `true` comes back — this
217
+ * collect owns it and may submit. Otherwise another collect got there first:
218
+ * its entry is copied into `run` and `false` comes back. An adopted entry
219
+ * with a batch id can be polled (polling is idempotent); one without an id
220
+ * is a claim whose owner has not recorded the id yet, and is reported as in
221
+ * flight elsewhere.
222
+ */
223
+ export async function claimRunSlot(broadsideDir, run, slot) {
224
+ let owned = false;
225
+ const claimedAt = new Date().toISOString();
226
+ await updateBroadsideStateAtomically(broadsideDir, (state) => {
227
+ const index = state.runs.findIndex((candidate) => candidate.id === run.id);
228
+ const onDisk = index === -1 ? undefined : state.runs[index];
229
+ const theirs = onDisk?.[slot];
230
+ const unclaimed = slot === "retry" ? theirs === undefined : theirs?.status === "pending";
231
+ if (onDisk && !unclaimed) {
232
+ run[slot] = theirs;
233
+ owned = false;
234
+ return;
235
+ }
236
+ owned = true;
237
+ if (slot === "retry") {
238
+ run.retry = { status: "submitted", batches: [], claimedAt };
239
+ }
240
+ else {
241
+ run[slot] = { ...run[slot], status: "submitted", batchId: undefined };
242
+ }
243
+ if (!onDisk) {
244
+ state.runs.push(run);
245
+ }
246
+ else {
247
+ onDisk[slot] = run[slot];
248
+ }
249
+ });
250
+ return owned;
251
+ }
252
+ /**
253
+ * Put a run's settled post-passes back to `pending` on disk so the next
254
+ * claim re-runs them (#338). A pass another collect has in flight is left
255
+ * alone — its result is still coming. The replaced results' cost moves to
256
+ * `retiredCost`, so the run's total keeps counting money it spent. Returns
257
+ * the passes that were reset, in the order they will be re-run.
258
+ */
259
+ export async function resetRunPostPasses(broadsideDir, run, wanted) {
260
+ const reset = [];
261
+ await updateBroadsideStateAtomically(broadsideDir, (state) => {
262
+ const index = state.runs.findIndex((candidate) => candidate.id === run.id);
263
+ const onDisk = index === -1 ? run : state.runs[index];
264
+ for (const kind of ["synthesis", "triage"]) {
265
+ if (!wanted[kind])
266
+ continue;
267
+ const theirs = onDisk[kind] ?? { status: "pending" };
268
+ if (theirs.status !== "completed" && theirs.status !== "failed") {
269
+ // pending: nothing to reset; submitted: in flight elsewhere.
270
+ run[kind] = theirs;
271
+ continue;
272
+ }
273
+ if (theirs.cost)
274
+ onDisk.retiredCost = (onDisk.retiredCost ?? 0) + theirs.cost;
275
+ onDisk[kind] = { status: "pending" };
276
+ run[kind] = onDisk[kind];
277
+ run.retiredCost = onDisk.retiredCost;
278
+ reset.push(kind);
279
+ }
280
+ if (index === -1)
281
+ state.runs.push(run);
282
+ });
283
+ return reset;
284
+ }
285
+ /** Read a `reasoning:` block from config.yaml, ignoring anything malformed. */
286
+ function parseReasoningConfig(raw) {
287
+ if (raw === false)
288
+ return { enabled: false };
289
+ if (raw === true)
290
+ return { enabled: true };
291
+ if (!raw || typeof raw !== "object")
292
+ return null;
293
+ const value = raw;
294
+ const out = {};
295
+ if (typeof value.enabled === "boolean")
296
+ out.enabled = value.enabled;
297
+ if (value.effort === "minimal" || value.effort === "low" || value.effort === "medium" || value.effort === "high")
298
+ out.effort = value.effort;
299
+ if (typeof value.max_tokens === "number" && value.max_tokens > 0)
300
+ out.max_tokens = value.max_tokens;
301
+ return Object.keys(out).length > 0 ? out : null;
302
+ }
303
+ export async function loadBroadsideConfig(broadsideDir) {
304
+ const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
305
+ let raw = {};
306
+ if (await pathExists(configPath)) {
307
+ let parsed;
308
+ try {
309
+ parsed = await loadYamlFile(configPath);
310
+ }
311
+ catch (error) {
312
+ throw new BroadsideConfigError(configPath, `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
313
+ }
314
+ if (parsed !== null && parsed !== undefined) {
315
+ if (typeof parsed !== "object" || Array.isArray(parsed))
316
+ throw new BroadsideConfigError(configPath, "is not a YAML mapping");
317
+ raw = parsed;
318
+ }
319
+ // OpenRouter accepts `reasoning.effort` or `reasoning.max_tokens`, not
320
+ // both: a request carrying both is refused per request *after* the batch
321
+ // is accepted, so every lens fails at $0 with the reason in each
322
+ // result's error. Seen live on 0.22.0 with the two keys set together.
323
+ // Refuse here, where the file can be fixed, rather than submit a run
324
+ // that cannot produce a result.
325
+ const reasoning = raw.reasoning;
326
+ if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) {
327
+ const value = reasoning;
328
+ const hasEffort = typeof value.effort === "string";
329
+ const hasBudget = typeof value.max_tokens === "number" && value.max_tokens > 0;
330
+ if (hasEffort && hasBudget) {
331
+ throw new BroadsideConfigError(configPath, 'sets both reasoning.effort and reasoning.max_tokens; OpenRouter accepts one or the other ("Only one of reasoning.effort and reasoning.max_tokens can be specified"), and every lens request would fail after the batch is accepted. Keep one');
332
+ }
333
+ }
334
+ }
335
+ return buildBroadsideConfig(raw);
336
+ }
337
+ /** The shipped defaults: what an absent config.yaml means. */
338
+ export function defaultBroadsideConfig() {
339
+ return buildBroadsideConfig({});
340
+ }
341
+ function buildBroadsideConfig(raw) {
342
+ const lenses = Array.isArray(raw.default_lenses)
343
+ ? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
344
+ : [];
345
+ const rawPricing = (raw.pricing ?? {});
346
+ const inputOverride = typeof rawPricing.input_per_m === "number" ? rawPricing.input_per_m : undefined;
347
+ const outputOverride = typeof rawPricing.output_per_m === "number" ? rawPricing.output_per_m : undefined;
348
+ // A malformed value falls back to the shipped default rather than failing
349
+ // the run: config.yaml is hand-edited, and a typo in a poll budget must not
350
+ // cost a user their batches.
351
+ const flag = (key, fallback) => typeof raw[key] === "boolean" ? raw[key] : fallback;
352
+ // An override for an unknown lens id is dropped rather than carried: it can
353
+ // only be a typo, and a silently-ignored key that looks applied is worse
354
+ // than one that never appears.
355
+ const lensModels = {};
356
+ const rawLensModels = (raw.lens_models ?? {});
357
+ for (const lensId of BROADSIDE_LENS_IDS) {
358
+ const value = rawLensModels[lensId];
359
+ if (typeof value === "string" && value.trim())
360
+ lensModels[lensId] = value.trim();
361
+ }
362
+ return {
363
+ model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
364
+ apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
365
+ defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
366
+ // Absent: the shipped default. An explicit 0 is "no limit", spelled out
367
+ // on purpose; a negative or non-numeric value is not a limit at all.
368
+ maxCost: typeof raw.max_cost === "number" && raw.max_cost >= 0 ? raw.max_cost : BROADSIDE_DEFAULT_MAX_COST,
369
+ pricing: inputOverride !== undefined && outputOverride !== undefined
370
+ ? { inputPerM: inputOverride, outputPerM: outputOverride }
371
+ : null,
372
+ lensModels,
373
+ // An escape hatch, not a knob to reach for: a model whose reasoning is
374
+ // worth paying for needs its lens maxTokens raised to cover both the
375
+ // thinking and the answer, or the JSON truncates exactly as before.
376
+ reasoning: parseReasoningConfig(raw.reasoning),
377
+ incremental: flag("incremental", false),
378
+ retryTruncated: flag("retry_truncated", true),
379
+ includeSynthesis: flag("include_synthesis", true),
380
+ includeTriage: flag("include_triage", true),
381
+ waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
382
+ redactSecrets: flag("redact_secrets", true),
383
+ };
384
+ }
@@ -0,0 +1,30 @@
1
+ import { type BroadsideLensId } from "./constants.ts";
2
+ import { type BroadsideEstimate, type BroadsideSubmitResult } from "./types.ts";
3
+ import { type FetchLike } from "./client.ts";
4
+ export declare function runBroadsideSubmit(cwd: string, apiKey: string, opts?: {
5
+ lenses?: BroadsideLensId[];
6
+ fetcher?: FetchLike;
7
+ model?: string;
8
+ /**
9
+ * Per-lens model overrides for this run, layered over config.yaml's
10
+ * `lens_models`: a lens named here runs on this model, a lens named only
11
+ * in the file runs on the file's, and the rest run on `model` (#141).
12
+ */
13
+ lensModels?: Partial<Record<BroadsideLensId, string>>;
14
+ /** Approximate run expense limit in USD; 0 means no limit. */
15
+ maxCost?: number;
16
+ /** Submit even when the estimate exceeds maxCost. */
17
+ force?: boolean;
18
+ /** Diff against the previous run's HEAD and scan only changed modules (#142). */
19
+ incremental?: boolean;
20
+ /**
21
+ * Called with the pre-flight estimate after slicing and before any state
22
+ * write or submission. Returning false throws {@link BroadsideCancelledError}
23
+ * and nothing is submitted; returning true proceeds even past maxCost,
24
+ * because an interactive approval of a priced run *is* the force flag.
25
+ *
26
+ * A surface that cannot ask a human (MCP) omits this and keeps the
27
+ * refuse-unless-force behavior.
28
+ */
29
+ confirm?: (estimate: BroadsideEstimate) => boolean | Promise<boolean>;
30
+ }): Promise<BroadsideSubmitResult>;