codecartographer-pi 0.22.0 → 0.22.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.
@@ -95,11 +95,14 @@ OpenRouter advertises a `:batch` variant for, and many of those variants do not
95
95
  exist — submitting one returns `does not have a :batch endpoint`, with nothing in
96
96
  the catalog to distinguish it beforehand. A rejected batch costs nothing, so
97
97
  probe a candidate on a single lens first. And reasoning competes with the answer for
98
- `max_tokens`: Broad-Side caps thinking at a quarter of each lens's output budget
99
- so three quarters remain for the JSON, which is the split the cost estimate
100
- already assumes. It caps rather than disables because some endpoints refuse to
101
- be switched off entirely. Override with `reasoning:` in `config.yaml` only
102
- alongside a raised output budget.
98
+ `max_tokens`: Broad-Side asks every model for low reasoning effort so the output
99
+ budget stays with the JSON, which is the split the cost estimate already
100
+ assumes. An effort level rather than a token cap, because Gemini 3.x ignores a
101
+ cap (measured: 11,518 thinking tokens under a 5,800 cap) and honours the level;
102
+ a level rather than off, because some endpoints refuse to be switched off. A
103
+ slice that still truncates is retried once with a doubled budget at low effort.
104
+ Override with `reasoning:` in `config.yaml` only alongside a raised output
105
+ budget, and set `effort` or `max_tokens`, never both.
103
106
 
104
107
  Lenses do not all have to run on the same model. `lens_models` in `config.yaml`
105
108
  routes individual lenses to their own batch model — the usual reason being that
@@ -72,29 +72,50 @@
72
72
 
73
73
  # Reasoning control, sent on every lens request.
74
74
  #
75
- # By default Broad-Side caps thinking at a quarter of the lens's output budget,
76
- # leaving the other three quarters for the answer — which is exactly the split
77
- # the cost estimate already assumes.
78
- #
79
- # The cap exists because reasoning competes with the answer for `max_tokens`.
80
- # One measured run spent 5,758 of a 6,000-token budget thinking and left ~230
81
- # tokens for the JSON, which truncated mid-structure on 11 of 13 slices; those
82
- # tokens bill at the full output rate, so it paid for ~6,000 output tokens per
83
- # slice to receive ~230 usable ones. The shipped default model does the same
84
- # thing less consistently — reasoning from 0 to 5,757 tokens across 13 slices,
85
- # three of them cut off — so this is not something only exotic models do.
86
- #
87
- # It is a cap rather than an off switch on purpose. Some endpoints refuse to be
88
- # switched off: `google/gemini-3.8-flash:batch` rejects the entire batch with
89
- # "Reasoning is mandatory for this endpoint and cannot be disabled", which turns
90
- # a partial result into none at all. Capping works either way.
91
- #
92
- # Override only with a raised lens output budget, or the JSON truncates exactly
93
- # as above.
75
+ # By default Broad-Side asks every model for low reasoning effort
76
+ # (`effort: low`), leaving the output budget for the answer — which is the
77
+ # split the cost estimate already assumes.
78
+ #
79
+ # The control exists because reasoning competes with the answer for
80
+ # `max_tokens`. One measured run spent 5,758 of a 6,000-token budget thinking
81
+ # and left ~230 tokens for the JSON, which truncated mid-structure on 11 of 13
82
+ # slices; those tokens bill at the full output rate, so it paid for ~6,000
83
+ # output tokens per slice to receive ~230 usable ones. The shipped default
84
+ # model does the same thing less consistently — reasoning from 0 to 5,757
85
+ # tokens across 13 slices, three of them cut off — so this is not something
86
+ # only exotic models do.
87
+ #
88
+ # It is an effort level rather than a token cap because a cap is not honoured
89
+ # everywhere. Gemini 3.x models take a thinking *level*, not a budget: under
90
+ # `max_tokens: 5800`, google/gemini-3.8-flash:batch reasoned 5,218 tokens on
91
+ # the first pass and 11,518 on the doubled-budget retry — the cap changed
92
+ # nothing, both results truncated, and the retry cost twice the original for
93
+ # no JSON. The same lens at `effort: low` reasoned 0 tokens, returned valid
94
+ # JSON, and cost a twelfth as much. `effort` is what OpenRouter can translate
95
+ # for every provider; a token cap reaches only the providers that take one.
96
+ #
97
+ # And it is a level rather than an off switch on purpose. Some endpoints refuse
98
+ # to be switched off: `google/gemini-3.8-flash:batch` rejects the entire batch
99
+ # with "Reasoning is mandatory for this endpoint and cannot be disabled", which
100
+ # turns a partial result into none at all. Low effort works either way.
101
+ #
102
+ # A slice that still truncates is re-submitted once with a doubled `max_tokens`
103
+ # and, whatever this block says, low effort — the cutoff is usually thinking.
104
+ #
105
+ # Set `effort` OR `max_tokens`, not both: OpenRouter refuses a request that
106
+ # carries both ("Only one of reasoning.effort and reasoning.max_tokens can be
107
+ # specified") — per request, after the batch is accepted, so every lens fails
108
+ # at $0. Submit refuses a config.yaml that sets both. Raise the effort only
109
+ # together with a raised lens output budget, or the JSON truncates exactly as
110
+ # above.
94
111
  #
95
112
  # reasoning:
96
113
  # effort: low # minimal | low | medium | high
97
- # max_tokens: 2000 # or set the thinking budget directly
114
+ #
115
+ # reasoning:
116
+ # max_tokens: 2000 # a thinking budget, where the provider honours one
117
+ #
118
+ # reasoning:
98
119
  # enabled: false # only where the provider allows it
99
120
 
100
121
  # Approximate run expense limit in USD. The default is 1.00, and it applies
@@ -3,4 +3,4 @@
3
3
  # workspace's framework-owned files (GUIDE.md, templates/, workflow/ pipelines
4
4
  # and VALIDATE.md) predate the running release. Written at release time and
5
5
  # copied verbatim by init — never edit by hand.
6
- scaffold_version: 0.22.0
6
+ scaffold_version: 0.22.1
@@ -137,31 +137,43 @@ export type BroadsideReasoning = {
137
137
  max_tokens?: number;
138
138
  };
139
139
  /**
140
- * The share of a lens's output budget reasoning may spend.
140
+ * The reasoning control every lens request carries: low effort.
141
141
  *
142
- * `estimateCost` already budgets output at 75% of `maxTokens`; capping thinking
143
- * at the remaining quarter makes that assumption true by construction and
144
- * guarantees the answer has room. A floor keeps the cap sane for a small lens.
142
+ * It used to be a token cap — `max_tokens` at a quarter of the lens's output
143
+ * budget, so three quarters stayed for the answer. Measured live on
144
+ * `google/gemini-3.8-flash:batch` (0.22.0 verification, defect lens, cap
145
+ * 5,800 of a 6,000 budget): the model reasoned 5,218 tokens on the first
146
+ * pass and **11,518 under the same cap** on the doubled-budget retry —
147
+ * thinking scaled with `max_tokens` and the cap changed nothing, both
148
+ * results truncated, and the retry cost twice the original for no JSON.
149
+ * The same lens with `effort: "low"` reasoned 0 tokens, finished with
150
+ * `stop`, returned valid JSON, and cost a twelfth as much. Gemini 3.x
151
+ * models take a thinking *level*, not a budget, and OpenRouter forwards a
152
+ * `max_tokens` cap to them as nothing at all; `effort` is what it can
153
+ * translate for every provider (a level where the provider has levels, a
154
+ * fraction of the budget where it takes a budget). So the default asks for
155
+ * little thinking in the one vocabulary that reaches everyone.
156
+ *
157
+ * Deliberately not `enabled: false`: `google/gemini-3.8-flash:batch` refuses
158
+ * the whole batch with *"Reasoning is mandatory for this endpoint and cannot
159
+ * be disabled"*, turning a partial result into none at all. Low effort works
160
+ * whether or not a provider allows reasoning to be switched off.
145
161
  */
146
- export declare const BROADSIDE_REASONING_BUDGET_FRACTION = 0.25;
147
- export declare const BROADSIDE_MIN_REASONING_TOKENS = 512;
162
+ export declare const BROADSIDE_DEFAULT_REASONING: Readonly<BroadsideReasoning>;
163
+ /** The reasoning control a lens request carries when config.yaml sets none. */
164
+ export declare function defaultReasoningFor(): BroadsideReasoning;
148
165
  /**
149
- * Cap reasoning for a lens request — deliberately a cap, not an off switch.
150
- *
151
- * Disabling outright is not portable: `google/gemini-3.8-flash:batch` refuses
152
- * the whole batch with *"Reasoning is mandatory for this endpoint and cannot be
153
- * disabled"*, turning a partial result into none at all. Capping works whether
154
- * or not a provider allows reasoning to be switched off.
166
+ * The reasoning control a truncated slice is re-submitted with.
155
167
  *
156
- * The failure this prevents is the budget being spent thinking rather than
157
- * answering. Measured on one run: 5,758 of a 6,000-token budget went to
158
- * reasoning, leaving ~230 tokens for JSON that truncated mid-structure — and
159
- * those tokens bill at the full output rate. The shipped default model does the
160
- * same thing less consistently (reasoning tokens from 0 to 5,757 across 13
161
- * slices, three of them cut off at `finish_reason: length`), so this is not a
162
- * multi-model concern.
168
+ * A truncation on a reasoning-capable model is usually thinking that ate the
169
+ * answer's budget, and doubling `max_tokens` doubles the thinking where the
170
+ * provider ignores a token cap (see {@link BROADSIDE_DEFAULT_REASONING}). The
171
+ * retry therefore asks for low effort as well, replacing a `max_tokens` cap
172
+ * (OpenRouter refuses a request carrying both) and lowering a higher effort.
173
+ * An explicit `enabled: false` and an effort already at or below low are left
174
+ * as they are.
163
175
  */
164
- export declare function defaultReasoningFor(maxTokens: number): BroadsideReasoning;
176
+ export declare function retryReasoningFor(original: BroadsideReasoning | undefined): BroadsideReasoning;
165
177
  export type BatchRequest = {
166
178
  custom_id: string;
167
179
  body: {
@@ -189,6 +201,8 @@ export type BroadsideBatchEntry = {
189
201
  cost?: number;
190
202
  resultCount?: number;
191
203
  error?: unknown;
204
+ /** Why a `skipped` lens had nothing to submit: the globs that matched no file. */
205
+ reason?: string;
192
206
  /** Set when this lens used a model other than the run default. */
193
207
  model?: string;
194
208
  /** The completion ceiling of this lens's model; bounds the truncation retry. */
@@ -704,6 +718,7 @@ export declare function runBroadsideStatus(cwd: string): Promise<{
704
718
  state: BroadsideStateFile;
705
719
  }>;
706
720
  export declare function renderFindingsMarkdown(content: string): string;
721
+ export declare function describeIncrementalFallback(reason: BroadsideIncrementalOutcome["reason"]): string;
707
722
  export declare function estimateSubmitText(result: BroadsideSubmitResult, lenses: LensDefinition[]): string;
708
723
  export declare function modelsText(entries: CatalogEntry[], opts: {
709
724
  benchmarks: CodingBenchmarks | null;
@@ -724,5 +739,12 @@ export declare function modelsText(entries: CatalogEntry[], opts: {
724
739
  */
725
740
  export declare function explainBatchError(error: unknown): string | null;
726
741
  export declare function collectResultText(result: BroadsideCollectResult): string;
742
+ /**
743
+ * An `onStatus` callback that appends one line to `lines` per *change* of a
744
+ * lens's polled status. Every poll used to append a line, so a four-minute
745
+ * wait returned twenty-six identical "in_progress (0/1)" lines per lens
746
+ * before the result (0.22.0 live run).
747
+ */
748
+ export declare function statusLineWriter(lines: string[]): (lensId: string, status: string, counts: Record<string, unknown>) => void;
727
749
  export declare function statusText(state: BroadsideStateFile): string;
728
750
  export {};
@@ -94,32 +94,51 @@ export const BROADSIDE_DEFAULT_POLL_BUDGET_MS = 25 * 60 * 1000;
94
94
  */
95
95
  export const BROADSIDE_DEFAULT_MAX_COST = 1;
96
96
  /**
97
- * The share of a lens's output budget reasoning may spend.
97
+ * The reasoning control every lens request carries: low effort.
98
98
  *
99
- * `estimateCost` already budgets output at 75% of `maxTokens`; capping thinking
100
- * at the remaining quarter makes that assumption true by construction and
101
- * guarantees the answer has room. A floor keeps the cap sane for a small lens.
99
+ * It used to be a token cap — `max_tokens` at a quarter of the lens's output
100
+ * budget, so three quarters stayed for the answer. Measured live on
101
+ * `google/gemini-3.8-flash:batch` (0.22.0 verification, defect lens, cap
102
+ * 5,800 of a 6,000 budget): the model reasoned 5,218 tokens on the first
103
+ * pass and **11,518 under the same cap** on the doubled-budget retry —
104
+ * thinking scaled with `max_tokens` and the cap changed nothing, both
105
+ * results truncated, and the retry cost twice the original for no JSON.
106
+ * The same lens with `effort: "low"` reasoned 0 tokens, finished with
107
+ * `stop`, returned valid JSON, and cost a twelfth as much. Gemini 3.x
108
+ * models take a thinking *level*, not a budget, and OpenRouter forwards a
109
+ * `max_tokens` cap to them as nothing at all; `effort` is what it can
110
+ * translate for every provider (a level where the provider has levels, a
111
+ * fraction of the budget where it takes a budget). So the default asks for
112
+ * little thinking in the one vocabulary that reaches everyone.
113
+ *
114
+ * Deliberately not `enabled: false`: `google/gemini-3.8-flash:batch` refuses
115
+ * the whole batch with *"Reasoning is mandatory for this endpoint and cannot
116
+ * be disabled"*, turning a partial result into none at all. Low effort works
117
+ * whether or not a provider allows reasoning to be switched off.
102
118
  */
103
- export const BROADSIDE_REASONING_BUDGET_FRACTION = 0.25;
104
- export const BROADSIDE_MIN_REASONING_TOKENS = 512;
119
+ export const BROADSIDE_DEFAULT_REASONING = Object.freeze({ effort: "low" });
120
+ /** The reasoning control a lens request carries when config.yaml sets none. */
121
+ export function defaultReasoningFor() {
122
+ return { ...BROADSIDE_DEFAULT_REASONING };
123
+ }
105
124
  /**
106
- * Cap reasoning for a lens request — deliberately a cap, not an off switch.
107
- *
108
- * Disabling outright is not portable: `google/gemini-3.8-flash:batch` refuses
109
- * the whole batch with *"Reasoning is mandatory for this endpoint and cannot be
110
- * disabled"*, turning a partial result into none at all. Capping works whether
111
- * or not a provider allows reasoning to be switched off.
125
+ * The reasoning control a truncated slice is re-submitted with.
112
126
  *
113
- * The failure this prevents is the budget being spent thinking rather than
114
- * answering. Measured on one run: 5,758 of a 6,000-token budget went to
115
- * reasoning, leaving ~230 tokens for JSON that truncated mid-structure — and
116
- * those tokens bill at the full output rate. The shipped default model does the
117
- * same thing less consistently (reasoning tokens from 0 to 5,757 across 13
118
- * slices, three of them cut off at `finish_reason: length`), so this is not a
119
- * multi-model concern.
127
+ * A truncation on a reasoning-capable model is usually thinking that ate the
128
+ * answer's budget, and doubling `max_tokens` doubles the thinking where the
129
+ * provider ignores a token cap (see {@link BROADSIDE_DEFAULT_REASONING}). The
130
+ * retry therefore asks for low effort as well, replacing a `max_tokens` cap
131
+ * (OpenRouter refuses a request carrying both) and lowering a higher effort.
132
+ * An explicit `enabled: false` and an effort already at or below low are left
133
+ * as they are.
120
134
  */
121
- export function defaultReasoningFor(maxTokens) {
122
- return { max_tokens: Math.max(BROADSIDE_MIN_REASONING_TOKENS, Math.floor(maxTokens * BROADSIDE_REASONING_BUDGET_FRACTION)) };
135
+ export function retryReasoningFor(original) {
136
+ if (original?.enabled === false)
137
+ return { ...original };
138
+ if (original?.effort === "minimal" || original?.effort === "low")
139
+ return { ...original };
140
+ const { max_tokens: _cap, effort: _effort, ...rest } = original ?? {};
141
+ return { ...rest, effort: "low" };
123
142
  }
124
143
  /**
125
144
  * OpenRouter rejected the API key (HTTP 401/403). Thrown from the catalog
@@ -1348,7 +1367,7 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
1348
1367
  max_tokens: maxTokensOverride ?? lens.maxTokens,
1349
1368
  // Always sent, never inherited: an absent field means the model's
1350
1369
  // own default, and that default is what truncated the JSON.
1351
- reasoning: reasoningOverride ?? lens.reasoning ?? defaultReasoningFor(maxTokensOverride ?? lens.maxTokens),
1370
+ reasoning: reasoningOverride ?? lens.reasoning ?? defaultReasoningFor(),
1352
1371
  },
1353
1372
  };
1354
1373
  }
@@ -1549,6 +1568,21 @@ export async function loadBroadsideConfig(broadsideDir) {
1549
1568
  throw new BroadsideConfigError(configPath, "is not a YAML mapping");
1550
1569
  raw = parsed;
1551
1570
  }
1571
+ // OpenRouter accepts `reasoning.effort` or `reasoning.max_tokens`, not
1572
+ // both: a request carrying both is refused per request *after* the batch
1573
+ // is accepted, so every lens fails at $0 with the reason in each
1574
+ // result's error. Seen live on 0.22.0 with the two keys set together.
1575
+ // Refuse here, where the file can be fixed, rather than submit a run
1576
+ // that cannot produce a result.
1577
+ const reasoning = raw.reasoning;
1578
+ if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) {
1579
+ const value = reasoning;
1580
+ const hasEffort = typeof value.effort === "string";
1581
+ const hasBudget = typeof value.max_tokens === "number" && value.max_tokens > 0;
1582
+ if (hasEffort && hasBudget) {
1583
+ 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');
1584
+ }
1585
+ }
1552
1586
  }
1553
1587
  return buildBroadsideConfig(raw);
1554
1588
  }
@@ -2132,6 +2166,8 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2132
2166
  }
2133
2167
  // Slice offline first so the estimate covers every request we would send.
2134
2168
  const slicesByLens = new Map();
2169
+ // Why a lens ended up with nothing to submit, for the report (see below).
2170
+ const skipReasons = new Map();
2135
2171
  let estimatedInputTokens = 0;
2136
2172
  let estimatedOutputTokens = 0;
2137
2173
  let estimatedTotalCost = 0;
@@ -2149,12 +2185,21 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2149
2185
  for (const file of slice.redactedFiles ?? [])
2150
2186
  redactedFiles.add(file);
2151
2187
  }
2188
+ const matchedBeforeIncremental = slices.length;
2152
2189
  if (changed) {
2153
2190
  // Repo-info slices (empty files, e.g. architecture) always run;
2154
2191
  // file-backed slices run only when one of their files changed.
2155
2192
  slices = slices.filter((s) => s.files.length === 0 || s.files.some((f) => changed.has(f)));
2156
2193
  }
2157
2194
  slicesByLens.set(lensId, slices);
2195
+ if (slices.length === 0) {
2196
+ const globs = lens.globsFor(info).filter(Boolean);
2197
+ skipReasons.set(lensId, globs.length === 0
2198
+ ? "the lens has no file patterns for this language"
2199
+ : matchedBeforeIncremental > 0
2200
+ ? "incremental: none of this lens's files changed since the previous run"
2201
+ : `no files matched ${globs.join(", ")}${lens.skipTestFiles ? " (test files excluded)" : ""}`);
2202
+ }
2158
2203
  const lensModel = modelForLens(lensId);
2159
2204
  const { pricing: lensPricing, outputCap: lensOutputCap } = resolved.get(lensModel);
2160
2205
  const maxTokens = lensOutputCap ? Math.min(lens.maxTokens, lensOutputCap) : lens.maxTokens;
@@ -2263,7 +2308,15 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2263
2308
  if (requests.length === 0) {
2264
2309
  // No files matched the lens's globs. That is a coverage gap to
2265
2310
  // report, not a batch to submit — the API rejects empty batches.
2311
+ // Name the globs: a JavaScript service whose server lives at
2312
+ // src/server.js gets no security review (that lens reads server/**,
2313
+ // **/auth*, **/middleware/**), and "skipped (0 request(s))" alone
2314
+ // read as an empty repository rather than a lens that looked in
2315
+ // the wrong place.
2266
2316
  entry.status = "skipped";
2317
+ const reason = skipReasons.get(lensId);
2318
+ if (reason)
2319
+ entry.reason = reason;
2267
2320
  continue;
2268
2321
  }
2269
2322
  submissions.push((async () => {
@@ -2284,6 +2337,12 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2284
2337
  })());
2285
2338
  }
2286
2339
  await Promise.allSettled(submissions);
2340
+ // A run with no batch behind it has nothing in flight. Every lens was
2341
+ // skipped or refused, so no poll will ever complete it; leaving it
2342
+ // "in-flight" had status listing a refused run above the completed ones
2343
+ // with synthesis and triage "pending" forever.
2344
+ if (!Object.values(run.batches).some((entry) => entry.batchId))
2345
+ run.status = "failed";
2287
2346
  await persistBroadsideRun(broadsideDir, run);
2288
2347
  // What the provider just said about each model's batch endpoint outlives
2289
2348
  // the run: the `models` action reads it back (#141).
@@ -2625,9 +2684,12 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2625
2684
  }
2626
2685
  await persistBroadsideRun(broadsideDir, run);
2627
2686
  }
2628
- // #133: re-submit truncated slices once with a bumped output cap. Batch
2629
- // requests are pure, so re-running is always safe; the aim is to recover
2630
- // coverage the first pass lost to a max_tokens cutoff, not to loop forever.
2687
+ // #133: re-submit truncated slices once with a bumped output cap and low
2688
+ // reasoning effort. Batch requests are pure, so re-running is always safe;
2689
+ // the aim is to recover coverage the first pass lost to a max_tokens
2690
+ // cutoff, not to loop forever. Low effort because the cutoff is usually
2691
+ // thinking, and a doubled budget doubled the thinking where a token cap
2692
+ // was ignored (see retryReasoningFor).
2631
2693
  //
2632
2694
  // All bumped requests for one model go out as ONE batch, and the batches
2633
2695
  // (one per model, since a batch carries a single model) are polled
@@ -2658,7 +2720,10 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2658
2720
  if (bumpedMax <= previousMax)
2659
2721
  continue; // already at the ceiling
2660
2722
  const group = byModel.get(lensModel) ?? { requests: [], slices: new Map() };
2661
- group.requests.push({ ...original, body: { ...original.body, max_tokens: bumpedMax } });
2723
+ group.requests.push({
2724
+ ...original,
2725
+ body: { ...original.body, max_tokens: bumpedMax, reasoning: retryReasoningFor(original.body.reasoning) },
2726
+ });
2662
2727
  group.slices.set(stored.customId, stored);
2663
2728
  byModel.set(lensModel, group);
2664
2729
  }
@@ -2959,7 +3024,7 @@ function parseSynthesisTopFindings(content) {
2959
3024
  }
2960
3025
  }
2961
3026
  // ---------- formatting helpers for tool output ----------
2962
- function describeIncrementalFallback(reason) {
3027
+ export function describeIncrementalFallback(reason) {
2963
3028
  switch (reason) {
2964
3029
  case "dirty-worktree":
2965
3030
  return "the working tree has uncommitted changes, so there is no committed state to diff against";
@@ -2993,8 +3058,13 @@ export function estimateSubmitText(result, lenses) {
2993
3058
  const override = entry.model ? ` on ${entry.model}` : "";
2994
3059
  // A rejected lens says why: the message is the only way to tell a
2995
3060
  // catalog id with no batch endpoint from a full job quota, and both
2996
- // used to read as a bare "rejected".
2997
- const reason = !entry.batchId && entry.error ? ` — ${explainBatchError(entry.error)}` : "";
3061
+ // used to read as a bare "rejected". A skipped lens names the globs
3062
+ // that matched nothing.
3063
+ const reason = !entry.batchId && entry.error
3064
+ ? ` — ${explainBatchError(entry.error)}`
3065
+ : !entry.batchId && entry.reason
3066
+ ? ` — ${entry.reason}`
3067
+ : "";
2998
3068
  lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}${reason}`);
2999
3069
  }
3000
3070
  if (result.repo) {
@@ -3178,6 +3248,22 @@ export function collectResultText(result) {
3178
3248
  lines.push("", "Disclaimer: Broad-Side findings are unverified scouting signals from a batch model, not validated claims.");
3179
3249
  return lines.join("\n");
3180
3250
  }
3251
+ /**
3252
+ * An `onStatus` callback that appends one line to `lines` per *change* of a
3253
+ * lens's polled status. Every poll used to append a line, so a four-minute
3254
+ * wait returned twenty-six identical "in_progress (0/1)" lines per lens
3255
+ * before the result (0.22.0 live run).
3256
+ */
3257
+ export function statusLineWriter(lines) {
3258
+ const last = new Map();
3259
+ return (lensId, status, counts) => {
3260
+ const line = ` ${lensId}: ${status} (${counts.completed ?? 0}/${counts.total ?? "?"})`;
3261
+ if (last.get(lensId) === line)
3262
+ return;
3263
+ last.set(lensId, line);
3264
+ lines.push(line);
3265
+ };
3266
+ }
3181
3267
  export function statusText(state) {
3182
3268
  if (state.runs.length === 0) {
3183
3269
  return "No Broad-Side runs recorded. Call codecarto_broadside with action 'submit' first.";
@@ -3195,7 +3281,8 @@ export function statusText(state) {
3195
3281
  const entry = run.batches[lensId];
3196
3282
  if (!entry)
3197
3283
  continue;
3198
- lines.push(` ${lensId}: ${entry.status}${entry.batchId ? ` (${entry.batchId})` : ""}${entry.cost !== undefined ? `, $${entry.cost.toFixed(6)}` : ""}`);
3284
+ lines.push(` ${lensId}: ${entry.status}${entry.batchId ? ` (${entry.batchId})` : ""}${entry.cost !== undefined ? `, $${entry.cost.toFixed(6)}` : ""}` +
3285
+ (entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : ""));
3199
3286
  }
3200
3287
  lines.push(` synthesis: ${run.synthesis.status}`);
3201
3288
  lines.push(` triage: ${run.triage?.status ?? "pending"}`);
@@ -9,7 +9,7 @@ import { parseNextFlags } from "./next-flags.js";
9
9
  import { buildPiGuideMessage } from "./guide-framing.js";
10
10
  import { isCtxLive, notifyCtx } from "./notify.js";
11
11
  import { phaseCompactionExtension } from "./phase-compaction.js";
12
- import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, expandTilde, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, describeConfigProblems, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../../core/index.js";
12
+ import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, expandTilde, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, describeConfigProblems, describeIncrementalFallback, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../../core/index.js";
13
13
  import { initLibrary } from "../../core/library.js";
14
14
  import { resolveUserConfigPath } from "../../core/orchestrator-config.js";
15
15
  const STATUS_WIDGET_ID = "codecarto-widget";
@@ -144,11 +144,14 @@ function describeBroadsideEstimate(estimate) {
144
144
  ? `This EXCEEDS the configured max_cost of $${estimate.maxCost.toFixed(2)}. Approving here overrides it for this run.`
145
145
  : `Within the configured max_cost of $${estimate.maxCost.toFixed(2)}.`);
146
146
  }
147
- if (estimate.baseHead) {
148
- lines.push(`Incremental: only modules changed since ${estimate.baseHead.slice(0, 8)} are included.`);
149
- }
150
- else if (estimate.sourceDirty) {
151
- lines.push("Incremental was requested but the tree is dirty — this is a full scan.");
147
+ // Only a requested incremental run has anything to say here. The dialog
148
+ // used to print "Incremental was requested but the tree is dirty" on every
149
+ // dirty tree, requested or not — a full scan that nobody asked to shrink
150
+ // read as a fallback.
151
+ if (estimate.incremental?.requested) {
152
+ lines.push(estimate.incremental.applied
153
+ ? `Incremental: only modules changed since ${(estimate.baseHead ?? "").slice(0, 8)} are included.`
154
+ : `Incremental was requested but NOT applied — ${describeIncrementalFallback(estimate.incremental.reason)}. This is a full scan.`);
152
155
  }
153
156
  lines.push("", "The estimate is a pre-flight prediction from file sizes; OpenRouter bills actual usage.");
154
157
  return lines.join("\n");
@@ -1114,6 +1117,9 @@ export default function codeCartographerExtension(pi) {
1114
1117
  const lenses = flags.lenses.length > 0 ? flags.lenses : config.defaultLenses;
1115
1118
  renderProgress("Slicing the repository and pricing the run…");
1116
1119
  let submit;
1120
+ // Set when a headless run was refused over max_cost, so the cancel
1121
+ // message says so instead of reading as a user's "no".
1122
+ let headlessRefusal = null;
1117
1123
  try {
1118
1124
  submit = await runBroadsideSubmit(ctx.cwd, apiKey, {
1119
1125
  lenses,
@@ -1128,14 +1134,32 @@ export default function codeCartographerExtension(pi) {
1128
1134
  incremental: flags.incremental ?? config.incremental,
1129
1135
  // Pi can ask, so it asks instead of refusing over max_cost the
1130
1136
  // way MCP has to. An approval here IS the force flag.
1131
- confirm: (estimate) => ctx.ui.confirm(`Broad-Side will spend about $${estimate.totalCost.toFixed(4)}`, describeBroadsideEstimate(estimate)),
1137
+ confirm: (estimate) => {
1138
+ if (ctx.hasUI) {
1139
+ return ctx.ui.confirm(`Broad-Side will spend about $${estimate.totalCost.toFixed(4)}`, describeBroadsideEstimate(estimate));
1140
+ }
1141
+ // No dialog under `pi -p`: the stub answered "no" to every
1142
+ // estimate, so a headless submit could never fire. Behave as
1143
+ // the MCP surface does — an estimate within max_cost is
1144
+ // approved by the cap itself; one over it is refused, since
1145
+ // nobody is here to say yes — and print the breakdown either
1146
+ // way, because the dialog was the only place it showed.
1147
+ notifyCtx(ctx, describeBroadsideEstimate(estimate), "info");
1148
+ if (estimate.exceedsLimit) {
1149
+ headlessRefusal =
1150
+ `Broad-Side refused: the estimate ~$${estimate.totalCost.toFixed(4)} exceeds max_cost $${estimate.maxCost.toFixed(2)} ` +
1151
+ "and there is no dialog to approve it in a headless run. Raise --max-cost (0 for no limit) or run interactively.";
1152
+ return false;
1153
+ }
1154
+ return true;
1155
+ },
1132
1156
  });
1133
1157
  }
1134
1158
  catch (error) {
1135
1159
  if (ctx.hasUI)
1136
1160
  ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
1137
1161
  if (error instanceof BroadsideCancelledError) {
1138
- notifyCtx(ctx, "Broad-Side cancelled. Nothing was submitted.", "info");
1162
+ notifyCtx(ctx, headlessRefusal ?? "Broad-Side cancelled. Nothing was submitted.", headlessRefusal ? "error" : "info");
1139
1163
  return;
1140
1164
  }
1141
1165
  notifyCtx(ctx, `Broad-Side submit failed: ${error instanceof Error ? error.message : String(error)}`, "error");
@@ -17,7 +17,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
17
17
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
18
18
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
19
19
  import { basename, isAbsolute, join } from "node:path";
20
- import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, previewPublishVersion, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, readBroadsideSkill, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, seedOrchestratorFiles, statusText, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../core/index.js";
20
+ import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, previewPublishVersion, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, readBroadsideSkill, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, seedOrchestratorFiles, statusLineWriter, statusText, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../core/index.js";
21
21
  import { applyAmendment } from "../core/amendment.js";
22
22
  import { appendUsageRun } from "../core/usage.js";
23
23
  import { initLibrary } from "../core/library.js";
@@ -1151,7 +1151,10 @@ export async function handleBroadside(args) {
1151
1151
  includeSynthesis,
1152
1152
  includeTriage,
1153
1153
  retryTruncated,
1154
- onStatus: (lensId, status, counts) => lines.push(` ${lensId}: ${status} (${counts.completed ?? 0}/${counts.total ?? "?"})`),
1154
+ // One line per *change* of a lens's status. Every poll used to
1155
+ // append a line, so a four-minute wait returned twenty-six
1156
+ // "in_progress (0/1)" lines before the result (0.22.0 live run).
1157
+ onStatus: statusLineWriter(lines),
1155
1158
  }).catch((error) => {
1156
1159
  // The `collect` action normalizes this same call; without it here,
1157
1160
  // a failure during submit-with-wait reached the client as an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "mcpName": "io.github.HuginnIndustries/codecartographer",
5
5
  "description": "Turn an unfamiliar codebase into a validated reimplementation spec, then synthesize confirmed specs and a product vision into a traceable plan.",
6
6
  "type": "module",