codecartographer-pi 0.20.0 → 0.22.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 (40) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/broadside/SKILL.md +15 -4
  3. package/.codecarto/broadside/config.yaml +26 -10
  4. package/.codecarto/findings/contracts/SKILL.md +4 -1
  5. package/.codecarto/findings/defect-scan/SKILL.md +10 -0
  6. package/.codecarto/findings/defect-scan-mechanical/SKILL.md +6 -0
  7. package/.codecarto/findings/defect-scan-semantic/SKILL.md +8 -1
  8. package/.codecarto/findings/porting/SKILL.md +4 -0
  9. package/.codecarto/findings/protocols/SKILL.md +4 -0
  10. package/.codecarto/templates/mechanical-defects.md +15 -0
  11. package/.codecarto/templates/reimplementation-spec.md +5 -3
  12. package/.codecarto/templates/reverse-engineering-bundle.md +10 -1
  13. package/.codecarto/templates/semantic-defects.md +15 -0
  14. package/.codecarto/workflow/VALIDATE.md +1 -1
  15. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  16. package/README.md +5 -5
  17. package/agent-skill/codecartographer/references/broadside.md +10 -0
  18. package/dist/core/amendment.js +9 -4
  19. package/dist/core/broadside.d.ts +98 -0
  20. package/dist/core/broadside.js +330 -63
  21. package/dist/core/completion.js +7 -1
  22. package/dist/core/library.js +5 -3
  23. package/dist/core/pipeline.d.ts +39 -3
  24. package/dist/core/pipeline.js +61 -7
  25. package/dist/core/prompts.js +10 -3
  26. package/dist/core/status.d.ts +8 -0
  27. package/dist/core/status.js +39 -17
  28. package/dist/core/utils.d.ts +7 -0
  29. package/dist/core/utils.js +7 -0
  30. package/dist/core/workspace.js +2 -2
  31. package/dist/core/yaml.js +8 -1
  32. package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
  33. package/dist/extensions/codecarto/auto-runner.js +18 -3
  34. package/dist/extensions/codecarto/broadside-flags.d.ts +7 -1
  35. package/dist/extensions/codecarto/broadside-flags.js +51 -0
  36. package/dist/extensions/codecarto/index.js +103 -35
  37. package/dist/extensions/codecarto/phase-compaction.js +5 -1
  38. package/dist/mcp-server/server.d.ts +5 -1
  39. package/dist/mcp-server/server.js +140 -32
  40. package/package.json +1 -1
@@ -31,11 +31,12 @@
31
31
  // executable surfaces (Pi and MCP), not the pure template. What the template does
32
32
  // carry is the reading guide for its output — `.codecarto/broadside/SKILL.md`,
33
33
  // served by codecarto_skill under the name `broadside` (see readBroadsideSkill).
34
+ import { createHash } from "node:crypto";
34
35
  import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
35
36
  import { execFile } from "node:child_process";
36
37
  import { promisify } from "node:util";
37
38
  import { join, relative } from "node:path";
38
- import { atomicWriteFile, pathExists, sleep } from "./utils.js";
39
+ import { atomicWriteFile, GIT_TIMEOUT_MS, pathExists, sleep } from "./utils.js";
39
40
  import { describeRedactions, isSecretFile, redactSecrets } from "./secrets.js";
40
41
  import { acquireLock } from "./status.js";
41
42
  import { loadYamlFile } from "./yaml.js";
@@ -64,6 +65,13 @@ export const BROADSIDE_OUTPUT_PRICE_PER_M = 1.875;
64
65
  export const BROADSIDE_MODELS_URL = "https://openrouter.ai/api/v1/models";
65
66
  export const BROADSIDE_BENCHMARKS_URL = "https://openrouter.ai/api/v1/benchmarks";
66
67
  export const BROADSIDE_CATALOG_CACHE_FILE = "model-catalog.json";
68
+ /**
69
+ * What this repository's own submits learned about batch endpoints: which
70
+ * `:batch` ids OpenRouter accepted a job for and which it refused with
71
+ * "does not have a :batch endpoint". The catalog cannot tell the two apart
72
+ * (#141), so the `models` action annotates its rows from this file.
73
+ */
74
+ export const BROADSIDE_ENDPOINTS_FILE = "batch-endpoints.json";
67
75
  export const BROADSIDE_CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
68
76
  export const BROADSIDE_LENS_IDS = [
69
77
  "architecture",
@@ -75,6 +83,16 @@ export const BROADSIDE_LENS_IDS = [
75
83
  ];
76
84
  export const BROADSIDE_POLL_INTERVAL_MS = 15_000;
77
85
  export const BROADSIDE_DEFAULT_POLL_BUDGET_MS = 25 * 60 * 1000;
86
+ /**
87
+ * The run expense limit in USD a repository gets before it configures one.
88
+ * Pi asks a human before submitting over the estimate; the MCP surface cannot,
89
+ * and shipped with no limit at all, so a host calling submit with the stock
90
+ * config spent whatever the estimate came to (#231). One dollar covers a
91
+ * six-lens run of a repository this size with room to spare; a larger one
92
+ * raises `max_cost` in config.yaml, passes `max_cost` on the call, or sets it
93
+ * to 0 for no limit.
94
+ */
95
+ export const BROADSIDE_DEFAULT_MAX_COST = 1;
78
96
  /**
79
97
  * The share of a lens's output budget reasoning may spend.
80
98
  *
@@ -120,6 +138,37 @@ export class BroadsideAuthError extends Error {
120
138
  this.detail = detail;
121
139
  }
122
140
  }
141
+ /**
142
+ * `broadside/config.yaml` exists but cannot be used. A file that failed to
143
+ * parse used to be treated exactly like an absent one — defaults, including
144
+ * no spend cap and no lens routing, with no message — so a typo removed the
145
+ * user's own guard (#232). Only an absent file yields defaults now.
146
+ */
147
+ export class BroadsideConfigError extends Error {
148
+ path;
149
+ constructor(path, detail) {
150
+ super(`Broad-Side config ${path} ${detail}. Fix or remove the file; nothing runs on defaults while it is unreadable.`);
151
+ this.name = "BroadsideConfigError";
152
+ this.path = path;
153
+ }
154
+ }
155
+ /**
156
+ * `broadside/state.json` exists but cannot be read. It used to be read as
157
+ * empty and the next checkpoint wrote that empty state over it, losing the
158
+ * batch ids of every in-flight, already-paid run (#233). The corrupt file is
159
+ * preserved beside itself and nothing writes over it until someone looks.
160
+ */
161
+ export class BroadsideStateError extends Error {
162
+ path;
163
+ backupPath;
164
+ constructor(path, backupPath, detail) {
165
+ super(`Broad-Side state ${path} ${detail}. A copy is preserved at ${backupPath}; the file is not overwritten. ` +
166
+ "Repair state.json from the copy (each run's batch ids are what collect needs), or move it aside to start fresh.");
167
+ this.name = "BroadsideStateError";
168
+ this.path = path;
169
+ this.backupPath = backupPath;
170
+ }
171
+ }
123
172
  /** Thrown when a confirm hook declines a run. Nothing was submitted. */
124
173
  export class BroadsideCancelledError extends Error {
125
174
  constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
@@ -851,9 +900,10 @@ const SOURCE_SPECS = {
851
900
  */
852
901
  async function listRepoFiles(targetDir) {
853
902
  try {
854
- const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024 });
903
+ const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
855
904
  const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
856
905
  maxBuffer: 64 * 1024 * 1024,
906
+ timeout: GIT_TIMEOUT_MS,
857
907
  });
858
908
  const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
859
909
  const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
@@ -865,7 +915,7 @@ async function listRepoFiles(targetDir) {
865
915
  }
866
916
  async function gitHead(targetDir) {
867
917
  try {
868
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024 });
918
+ const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
869
919
  return stdout.trim() || null;
870
920
  }
871
921
  catch {
@@ -874,7 +924,7 @@ async function gitHead(targetDir) {
874
924
  }
875
925
  async function gitDirty(targetDir) {
876
926
  try {
877
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024 });
927
+ const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
878
928
  return stdout.trim().length > 0;
879
929
  }
880
930
  catch {
@@ -890,7 +940,7 @@ async function changedFilesSince(targetDir, baseHead) {
890
940
  if (!baseHead)
891
941
  return null;
892
942
  try {
893
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024 });
943
+ const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
894
944
  return new Set(stdout.split("\n").filter(Boolean));
895
945
  }
896
946
  catch {
@@ -909,7 +959,7 @@ async function walkFiles(rootDir, dir, depth, remaining) {
909
959
  return out;
910
960
  }
911
961
  for (const entry of entries) {
912
- if (entry.name.startsWith(".") && entry.name !== ".github")
962
+ if (entry.name.startsWith("."))
913
963
  continue;
914
964
  if (entry.isDirectory()) {
915
965
  if (SKIP_DIR_NAMES.has(entry.name))
@@ -1371,15 +1421,30 @@ export async function loadBroadsideState(broadsideDir) {
1371
1421
  const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
1372
1422
  if (!(await pathExists(statePath)))
1373
1423
  return defaultBroadsideState();
1424
+ const text = await readFile(statePath, "utf8");
1425
+ let raw;
1374
1426
  try {
1375
- const raw = JSON.parse(await readFile(statePath, "utf8"));
1376
- if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs))
1377
- return defaultBroadsideState();
1378
- return raw;
1427
+ raw = JSON.parse(text);
1379
1428
  }
1380
- catch {
1381
- return defaultBroadsideState();
1429
+ catch (error) {
1430
+ throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
1431
+ }
1432
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs)) {
1433
+ throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), "is not a state file (expected an object with a runs array)");
1382
1434
  }
1435
+ return raw;
1436
+ }
1437
+ /**
1438
+ * Copy an unreadable state file to `state.json.corrupt-<hash>` beside it,
1439
+ * named by content so repeated loads do not multiply copies. Returns the
1440
+ * copy's path (the existing one, when the same content was preserved before).
1441
+ */
1442
+ async function preserveCorruptState(statePath, text) {
1443
+ const digest = createHash("sha1").update(text).digest("hex").slice(0, 8);
1444
+ const backupPath = `${statePath}.corrupt-${digest}`;
1445
+ if (!(await pathExists(backupPath)))
1446
+ await writeFile(backupPath, text, "utf8");
1447
+ return backupPath;
1383
1448
  }
1384
1449
  /**
1385
1450
  * Overwrite `state.json` wholesale with `state`.
@@ -1472,13 +1537,26 @@ export async function loadBroadsideConfig(broadsideDir) {
1472
1537
  const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
1473
1538
  let raw = {};
1474
1539
  if (await pathExists(configPath)) {
1540
+ let parsed;
1475
1541
  try {
1476
- raw = (await loadYamlFile(configPath)) ?? {};
1542
+ parsed = await loadYamlFile(configPath);
1477
1543
  }
1478
- catch {
1479
- raw = {};
1544
+ catch (error) {
1545
+ throw new BroadsideConfigError(configPath, `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
1546
+ }
1547
+ if (parsed !== null && parsed !== undefined) {
1548
+ if (typeof parsed !== "object" || Array.isArray(parsed))
1549
+ throw new BroadsideConfigError(configPath, "is not a YAML mapping");
1550
+ raw = parsed;
1480
1551
  }
1481
1552
  }
1553
+ return buildBroadsideConfig(raw);
1554
+ }
1555
+ /** The shipped defaults: what an absent config.yaml means. */
1556
+ export function defaultBroadsideConfig() {
1557
+ return buildBroadsideConfig({});
1558
+ }
1559
+ function buildBroadsideConfig(raw) {
1482
1560
  const lenses = Array.isArray(raw.default_lenses)
1483
1561
  ? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
1484
1562
  : [];
@@ -1503,7 +1581,9 @@ export async function loadBroadsideConfig(broadsideDir) {
1503
1581
  model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
1504
1582
  apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
1505
1583
  defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
1506
- maxCost: typeof raw.max_cost === "number" && raw.max_cost > 0 ? raw.max_cost : 0,
1584
+ // Absent: the shipped default. An explicit 0 is "no limit", spelled out
1585
+ // on purpose; a negative or non-numeric value is not a limit at all.
1586
+ maxCost: typeof raw.max_cost === "number" && raw.max_cost >= 0 ? raw.max_cost : BROADSIDE_DEFAULT_MAX_COST,
1507
1587
  pricing: inputOverride !== undefined && outputOverride !== undefined
1508
1588
  ? { inputPerM: inputOverride, outputPerM: outputOverride }
1509
1589
  : null,
@@ -1520,13 +1600,18 @@ export async function loadBroadsideConfig(broadsideDir) {
1520
1600
  redactSecrets: flag("redact_secrets", true),
1521
1601
  };
1522
1602
  }
1603
+ // ---------- model catalog, pricing, benchmarks ----------
1604
+ /** The catalog cache schema this build writes; a file from another is not read. */
1605
+ export const BROADSIDE_CATALOG_CACHE_SCHEMA = 3;
1523
1606
  async function readCatalogCache(broadsideDir) {
1524
1607
  const cachePath = join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE);
1525
1608
  if (!(await pathExists(cachePath)))
1526
1609
  return null;
1527
1610
  try {
1528
1611
  const parsed = JSON.parse(await readFile(cachePath, "utf8"));
1529
- if (!parsed || typeof parsed !== "object" || typeof parsed.models !== "object")
1612
+ if (!parsed || typeof parsed !== "object" || !parsed.models || typeof parsed.models !== "object")
1613
+ return null;
1614
+ if (parsed.schema_version !== BROADSIDE_CATALOG_CACHE_SCHEMA && parsed.schema_version !== 2)
1530
1615
  return null;
1531
1616
  return parsed;
1532
1617
  }
@@ -1534,10 +1619,76 @@ async function readCatalogCache(broadsideDir) {
1534
1619
  return null;
1535
1620
  }
1536
1621
  }
1622
+ /** When a cached entry was fetched: its own stamp, or the file's for a schema-2 cache. */
1623
+ function catalogEntryFetchedAt(cache, model) {
1624
+ const stamp = cache.models[model]?.fetched_at ?? cache.fetched_at;
1625
+ return new Date(stamp).getTime();
1626
+ }
1537
1627
  async function writeCatalogCache(broadsideDir, cache) {
1538
1628
  await mkdir(broadsideDir, { recursive: true });
1539
1629
  await writeFile(join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE), `${JSON.stringify(cache, null, "\t")}\n`, "utf8");
1540
1630
  }
1631
+ const BROADSIDE_ENDPOINTS_SCHEMA = 1;
1632
+ export async function readBatchEndpoints(broadsideDir) {
1633
+ const path = join(broadsideDir, BROADSIDE_ENDPOINTS_FILE);
1634
+ if (!(await pathExists(path)))
1635
+ return {};
1636
+ try {
1637
+ const parsed = JSON.parse(await readFile(path, "utf8"));
1638
+ if (!parsed || typeof parsed !== "object" || parsed.schema_version !== BROADSIDE_ENDPOINTS_SCHEMA)
1639
+ return {};
1640
+ if (!parsed.models || typeof parsed.models !== "object")
1641
+ return {};
1642
+ const out = {};
1643
+ for (const [model, record] of Object.entries(parsed.models)) {
1644
+ if (!record || typeof record !== "object")
1645
+ continue;
1646
+ if (record.status !== "accepted" && record.status !== "rejected")
1647
+ continue;
1648
+ if (typeof record.at !== "string")
1649
+ continue;
1650
+ out[model] = { status: record.status, at: record.at, ...(typeof record.error === "string" && { error: record.error }) };
1651
+ }
1652
+ return out;
1653
+ }
1654
+ catch {
1655
+ // An unreadable memory is an empty one: it only annotates a listing.
1656
+ return {};
1657
+ }
1658
+ }
1659
+ /**
1660
+ * The refusal OpenRouter returns for a catalog id that has no batch endpoint
1661
+ * behind it. Matched loosely: the message is the only signal there is.
1662
+ */
1663
+ const NO_BATCH_ENDPOINT_RE = /does not have a :batch endpoint/i;
1664
+ /** The refusal for a full per-account concurrent batch-job quota. */
1665
+ const BATCH_QUOTA_RE = /job-submission-count/i;
1666
+ /**
1667
+ * Remember what a submit learned about each model it posted to. An accepted
1668
+ * job proves the endpoint exists; a "does not have a :batch endpoint"
1669
+ * refusal proves it does not. Any other rejection (quota, malformed request,
1670
+ * auth) says nothing about the endpoint and leaves the record alone.
1671
+ */
1672
+ export async function recordBatchEndpoints(broadsideDir, outcomes) {
1673
+ const at = new Date().toISOString();
1674
+ const updates = {};
1675
+ for (const { model, batchId, error } of outcomes) {
1676
+ if (batchId) {
1677
+ updates[model] = { status: "accepted", at };
1678
+ continue;
1679
+ }
1680
+ const message = describeBatchError(error);
1681
+ if (message && NO_BATCH_ENDPOINT_RE.test(message)) {
1682
+ updates[model] = { status: "rejected", at, error: message };
1683
+ }
1684
+ }
1685
+ if (Object.keys(updates).length === 0)
1686
+ return;
1687
+ const models = { ...(await readBatchEndpoints(broadsideDir)), ...updates };
1688
+ await mkdir(broadsideDir, { recursive: true });
1689
+ const file = { schema_version: BROADSIDE_ENDPOINTS_SCHEMA, models };
1690
+ await atomicWriteFile(join(broadsideDir, BROADSIDE_ENDPOINTS_FILE), `${JSON.stringify(file, null, "\t")}\n`);
1691
+ }
1541
1692
  function parseCatalogEntry(raw) {
1542
1693
  const id = String(raw.id ?? "");
1543
1694
  if (!id)
@@ -1613,7 +1764,7 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
1613
1764
  // constants below are what we fall back to when the network is unavailable.
1614
1765
  const cache = await readCatalogCache(broadsideDir);
1615
1766
  const cached = cache?.models[model];
1616
- if (cached && Date.now() - new Date(cache.fetched_at).getTime() < BROADSIDE_CATALOG_CACHE_TTL_MS) {
1767
+ if (cache && cached && Date.now() - catalogEntryFetchedAt(cache, model) < BROADSIDE_CATALOG_CACHE_TTL_MS) {
1617
1768
  return { model, source: "cache", entry: cached };
1618
1769
  }
1619
1770
  // What went wrong when the live lookup produced nothing, for the error
@@ -1649,12 +1800,15 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
1649
1800
  catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
1650
1801
  }
1651
1802
  if (live) {
1803
+ const now = new Date().toISOString();
1652
1804
  const updated = {
1653
- schema_version: 2,
1654
- fetched_at: new Date().toISOString(),
1655
- models: { ...(cache?.models ?? {}) },
1805
+ schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA,
1806
+ fetched_at: now,
1807
+ // Other entries keep their own stamps (a schema-2 file's entries
1808
+ // inherit the file's, once, on this upgrade); only this model is fresh.
1809
+ models: Object.fromEntries(Object.entries(cache?.models ?? {}).map(([id, entry]) => [id, { ...entry, fetched_at: entry.fetched_at ?? cache.fetched_at }])),
1656
1810
  };
1657
- updated.models[model] = live;
1811
+ updated.models[model] = { ...live, fetched_at: now };
1658
1812
  await writeCatalogCache(broadsideDir, updated);
1659
1813
  return { model, source: "live", entry: live };
1660
1814
  }
@@ -1751,12 +1905,14 @@ export async function listBatchModels(broadsideDir, config, apiKey, opts = {}) {
1751
1905
  }
1752
1906
  entries.sort((a, b) => a.inputPerM + a.outputPerM - (b.inputPerM + b.outputPerM));
1753
1907
  // Persist the catalog so the next submit's pricing resolution hits cache.
1754
- const cache = { schema_version: 2, fetched_at: new Date().toISOString(), models: {} };
1908
+ const fetchedAt = new Date().toISOString();
1909
+ const cache = { schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA, fetched_at: fetchedAt, models: {} };
1755
1910
  for (const entry of entries)
1756
- cache.models[entry.id] = entry;
1911
+ cache.models[entry.id] = { ...entry, fetched_at: fetchedAt };
1757
1912
  await writeCatalogCache(broadsideDir, cache);
1758
1913
  const benchmarks = opts.includeBenchmarks ? await fetchCodingBenchmarks(apiKey, fetcher) : null;
1759
- return { entries, source: "live", benchmarks, defaultModel: config.model };
1914
+ const endpoints = await readBatchEndpoints(broadsideDir);
1915
+ return { entries, source: "live", benchmarks, defaultModel: config.model, endpoints };
1760
1916
  }
1761
1917
  export async function submitBatch(batchRequests, apiKey, fetcher = fetch, model = BROADSIDE_MODEL) {
1762
1918
  // The OpenRouter batch endpoint stream-parses the body and requires
@@ -1811,6 +1967,14 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
1811
1967
  * charged, so callers must come back for it rather than retire it.
1812
1968
  */
1813
1969
  export const BROADSIDE_DEAD_BATCH_STATUSES = ["failed", "expired", "cancelled", "auth-failed"];
1970
+ /**
1971
+ * Batch entry statuses collect never polls again: the dead ones above, plus
1972
+ * `completed`, plus the two a submit assigns without a batch (`skipped`: no
1973
+ * matching files; `rejected`: the provider refused it). The 0.19.1 changelog
1974
+ * called the dead set "a named constant rather than two hand-maintained
1975
+ * lists"; this set was still three literal copies (self-audit sem 5.8).
1976
+ */
1977
+ export const BROADSIDE_TERMINAL_ENTRY_STATUSES = ["completed", ...BROADSIDE_DEAD_BATCH_STATUSES, "skipped", "rejected"];
1814
1978
  export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
1815
1979
  const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
1816
1980
  const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
@@ -1906,7 +2070,8 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1906
2070
  throw new Error(`Broad-Side found no ${info.language} source files to scan (detected from ${info.manifest?.path ?? "the file counts"}; ` +
1907
2071
  `the lenses look for ${info.sourceExts.join(", ")}). Nothing was submitted.`);
1908
2072
  }
1909
- const modelForLens = (lensId) => config.lensModels[lensId] ?? model;
2073
+ const lensModels = { ...config.lensModels, ...opts.lensModels };
2074
+ const modelForLens = (lensId) => lensModels[lensId] ?? model;
1910
2075
  const resolved = new Map();
1911
2076
  for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
1912
2077
  const catalog = await resolveCatalogEntry(broadsideDir, config, candidate, apiKey, opts.fetcher);
@@ -2042,7 +2207,9 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2042
2207
  `$${limit.toFixed(2)}. Nothing was submitted.\nBreakdown:\n${breakdown}\n` +
2043
2208
  `Pass force: true to submit anyway, or raise max_cost in .codecarto/broadside/config.yaml.`);
2044
2209
  }
2045
- const state = await loadBroadsideState(broadsideDir);
2210
+ // Read before anything is posted: a state.json that cannot be read refuses
2211
+ // the run here (#233), while persistBroadsideRun below merges by run id.
2212
+ await loadBroadsideState(broadsideDir);
2046
2213
  const runId = new Date().toISOString().replace(/[:.]/g, "-");
2047
2214
  const run = {
2048
2215
  id: runId,
@@ -2069,7 +2236,6 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2069
2236
  skippedFiles: info.secretFilesSkipped.length,
2070
2237
  },
2071
2238
  };
2072
- state.runs.push(run);
2073
2239
  await persistBroadsideRun(broadsideDir, run);
2074
2240
  const requestsByCustomId = {};
2075
2241
  const submissions = [];
@@ -2119,6 +2285,12 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2119
2285
  }
2120
2286
  await Promise.allSettled(submissions);
2121
2287
  await persistBroadsideRun(broadsideDir, run);
2288
+ // What the provider just said about each model's batch endpoint outlives
2289
+ // the run: the `models` action reads it back (#141).
2290
+ await recordBatchEndpoints(broadsideDir, lensIds
2291
+ .map((lensId) => run.batches[lensId])
2292
+ .filter((entry) => Boolean(entry) && entry.status !== "skipped")
2293
+ .map((entry) => ({ model: entry.model ?? model, batchId: entry.batchId, error: entry.error })));
2122
2294
  // Persist the exact request bodies so collect can re-submit a truncated
2123
2295
  // slice (bumped output cap) without re-walking the repo (#133). The run
2124
2296
  // dir is created here rather than waiting for collect so a crash between
@@ -2364,8 +2536,13 @@ function parseTriageItems(content) {
2364
2536
  export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2365
2537
  const broadsideDir = broadsideDirFor(cwd);
2366
2538
  const state = await loadBroadsideState(broadsideDir);
2367
- const run = state.runs[state.runs.length - 1];
2539
+ const run = opts.runId ? state.runs.find((candidate) => candidate.id === opts.runId) : state.runs[state.runs.length - 1];
2368
2540
  if (!run) {
2541
+ if (opts.runId) {
2542
+ const known = state.runs.map((candidate) => candidate.id);
2543
+ throw new Error(`No Broad-Side run with id ${opts.runId}. ` +
2544
+ (known.length > 0 ? `Recorded runs: ${known.join(", ")}.` : "No runs are recorded; call codecarto_broadside with action 'submit' first."));
2545
+ }
2369
2546
  throw new Error("No Broad-Side run recorded. Call codecarto_broadside with action 'submit' first.");
2370
2547
  }
2371
2548
  const runDir = join(broadsideDir, run.outputDir);
@@ -2386,7 +2563,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2386
2563
  lensOutcomes[lensId] = { status: entry?.status ?? "failed", resultCount: 0 };
2387
2564
  continue;
2388
2565
  }
2389
- if (["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status)) {
2566
+ if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status)) {
2390
2567
  totalCost += entry.cost ?? 0;
2391
2568
  resultCount += entry.resultCount ?? 0;
2392
2569
  lensOutcomes[lensId] = { status: entry.status, cost: entry.cost, resultCount: entry.resultCount };
@@ -2419,7 +2596,19 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2419
2596
  truncatedCount += truncated;
2420
2597
  totalCost += cost ?? 0;
2421
2598
  await writeFile(join(runDir, `raw-${lensId}.json`), `${JSON.stringify(batch, null, "\t")}\n`, "utf8");
2422
- lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, truncated };
2599
+ // A batch can complete with every request failed — the account's
2600
+ // concurrent-job quota filling after acceptance does exactly this.
2601
+ // The per-request errors are on disk as `<id>.error.json`, but a
2602
+ // lens reporting "completed, 0 result(s)" with the reason buried
2603
+ // there read as an empty repository rather than a refused run.
2604
+ const results = Array.isArray(batch.results) ? batch.results : [];
2605
+ const failed = results.filter((r) => r.error && extractContent(r) === null);
2606
+ const allFailed = stored.length === 0 && failed.length > 0
2607
+ ? `all ${failed.length} request(s) failed: ${explainBatchError(failed[0].error)}`
2608
+ : null;
2609
+ if (allFailed)
2610
+ entry.error = allFailed;
2611
+ lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, truncated, ...(allFailed && { error: allFailed }) };
2423
2612
  }
2424
2613
  else {
2425
2614
  // Every non-completed outcome still has to reach the report.
@@ -2431,7 +2620,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2431
2620
  // indistinguishable in the output from one that was never requested.
2432
2621
  if (batch.error)
2433
2622
  entry.error = batch.error;
2434
- const error = describeBatchError(batch.error);
2623
+ const error = explainBatchError(batch.error);
2435
2624
  lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
2436
2625
  }
2437
2626
  await persistBroadsideRun(broadsideDir, run);
@@ -2439,9 +2628,19 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2439
2628
  // #133: re-submit truncated slices once with a bumped output cap. Batch
2440
2629
  // requests are pure, so re-running is always safe; the aim is to recover
2441
2630
  // coverage the first pass lost to a max_tokens cutoff, not to loop forever.
2631
+ //
2632
+ // All bumped requests for one model go out as ONE batch, and the batches
2633
+ // (one per model, since a batch carries a single model) are polled
2634
+ // together against the shared deadline. Each truncated slice used to be
2635
+ // submitted and polled to terminal before the next was submitted, so a
2636
+ // model that truncated 11 of 13 slices turned a five-minute collect into
2637
+ // eleven sequential round trips — the serialization #136 removed from the
2638
+ // lens pass, still present here (#206). Grouping also keeps the retry to
2639
+ // one job per model against OpenRouter's 16-concurrent-job quota.
2442
2640
  let retriedCount = 0;
2443
2641
  if (opts.retryTruncated !== false && truncatedCount > 0) {
2444
2642
  const requestsByCustomId = await loadStoredRequests(runDir);
2643
+ const byModel = new Map();
2445
2644
  for (const stored of allLensResults) {
2446
2645
  if (!stored.truncated)
2447
2646
  continue;
@@ -2458,42 +2657,57 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2458
2657
  const bumpedMax = lensCap ? Math.min(previousMax * 2, lensCap) : previousMax * 2;
2459
2658
  if (bumpedMax <= previousMax)
2460
2659
  continue; // already at the ceiling
2461
- const bumped = {
2462
- ...original,
2463
- body: { ...original.body, max_tokens: bumpedMax },
2464
- };
2660
+ const group = byModel.get(lensModel) ?? { requests: [], slices: new Map() };
2661
+ group.requests.push({ ...original, body: { ...original.body, max_tokens: bumpedMax } });
2662
+ group.slices.set(stored.customId, stored);
2663
+ byModel.set(lensModel, group);
2664
+ }
2665
+ // Submit every group, then poll whatever was accepted, together.
2666
+ const submitted = [];
2667
+ for (const [model, group] of byModel) {
2465
2668
  try {
2466
- const { batchId, error } = await submitBatch([bumped], apiKey, opts.fetcher, lensModel);
2467
- if (error)
2669
+ const { batchId, error } = await submitBatch(group.requests, apiKey, opts.fetcher, model);
2670
+ if (!error && batchId)
2671
+ submitted.push({ model, batchId });
2672
+ }
2673
+ catch {
2674
+ // A retry batch that fails to submit leaves its slices' original
2675
+ // truncated results in place — nothing is lost.
2676
+ }
2677
+ }
2678
+ const polled = await pollBatchesConcurrently(submitted.map(({ model, batchId }) => ({ lensId: `retry:${model}`, batchId })), apiKey, {
2679
+ // Share the caller's deadline. Each of these polls used to start a
2680
+ // fresh 25-minute budget, so `wait_seconds` bounded only the lens
2681
+ // poll and a collect could run for the caller's budget plus fifty
2682
+ // minutes.
2683
+ deadlineMs: Math.max(0, deadline - Date.now()),
2684
+ fetcher: opts.fetcher,
2685
+ onStatus: opts.onStatus,
2686
+ });
2687
+ for (const { model, batchId } of submitted) {
2688
+ const batch = polled.get(batchId);
2689
+ if (!batch || batch.status !== "completed")
2690
+ continue;
2691
+ const group = byModel.get(model);
2692
+ const usage = (batch.usage ?? {});
2693
+ totalCost += typeof usage.cost === "number" ? usage.cost : 0;
2694
+ const results = Array.isArray(batch.results) ? batch.results : [];
2695
+ for (const result of results) {
2696
+ const stored = group.slices.get(String(result.custom_id ?? ""));
2697
+ if (!stored)
2468
2698
  continue;
2469
- const batch = await pollBatchUntilTerminal(batchId, apiKey, {
2470
- // Share the caller's deadline. Each of these polls used to
2471
- // start a fresh 25-minute budget, so `wait_seconds` bounded
2472
- // only the lens poll and a collect could run for the caller's
2473
- // budget plus fifty minutes.
2474
- deadlineMs: Math.max(0, deadline - Date.now()),
2475
- onStatus: (status, counts) => opts.onStatus?.(`${stored.lensId}:retry`, status, counts),
2476
- fetcher: opts.fetcher,
2477
- });
2478
- if (batch.status !== "completed")
2699
+ const content = extractContent(result);
2700
+ if (content === null)
2479
2701
  continue;
2480
- const results = Array.isArray(batch.results) ? batch.results : [];
2481
- const content = results.length > 0 ? extractContent(results[0]) : null;
2482
- if (content === null || parseLensJson(content) === null)
2483
- continue; // still no good
2484
- const usage = (batch.usage ?? {});
2485
- totalCost += typeof usage.cost === "number" ? usage.cost : 0;
2486
2702
  const parsed = parseLensJson(content);
2703
+ if (parsed === null)
2704
+ continue; // still no good
2487
2705
  await writeFile(join(runDir, `${sanitizeId(stored.customId)}.json`), `${JSON.stringify(parsed, null, "\t")}\n`, "utf8");
2488
2706
  await writeFile(join(runDir, `${sanitizeId(stored.customId)}.md`), renderFindingsMarkdown(content), "utf8");
2489
2707
  stored.content = content;
2490
2708
  stored.truncated = false;
2491
2709
  retriedCount += 1;
2492
2710
  }
2493
- catch {
2494
- // A retry that fails to submit/poll leaves the original
2495
- // truncated result in place — nothing is lost.
2496
- }
2497
2711
  }
2498
2712
  truncatedCount = allLensResults.filter((s) => s.truncated).length;
2499
2713
  for (const [lensId, outcome] of Object.entries(lensOutcomes)) {
@@ -2526,7 +2740,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2526
2740
  if ((wantSynthesis || wantTriage) && allLensResults.length > 0) {
2527
2741
  const allTerminal = run.lenses.every((lensId) => {
2528
2742
  const entry = run.batches[lensId];
2529
- return entry && ["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status);
2743
+ return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
2530
2744
  });
2531
2745
  if (allTerminal && (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
2532
2746
  const findingsText = allLensResults
@@ -2638,7 +2852,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2638
2852
  }
2639
2853
  const terminal = run.lenses.every((lensId) => {
2640
2854
  const entry = run.batches[lensId];
2641
- return entry && ["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status);
2855
+ return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
2642
2856
  });
2643
2857
  run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
2644
2858
  run.totalCost = totalCost;
@@ -2777,7 +2991,11 @@ export function estimateSubmitText(result, lenses) {
2777
2991
  continue;
2778
2992
  const status = entry.batchId ? `batch ${entry.batchId}` : entry.status;
2779
2993
  const override = entry.model ? ` on ${entry.model}` : "";
2780
- lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}`);
2994
+ // A rejected lens says why: the message is the only way to tell a
2995
+ // 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)}` : "";
2998
+ lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}${reason}`);
2781
2999
  }
2782
3000
  if (result.repo) {
2783
3001
  const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
@@ -2814,8 +3032,15 @@ export function estimateSubmitText(result, lenses) {
2814
3032
  return lines.join("\n");
2815
3033
  }
2816
3034
  export function modelsText(entries, opts) {
3035
+ const endpoints = opts.endpoints ?? {};
2817
3036
  const lines = [
2818
3037
  `Batch models on OpenRouter (${entries.length}, cheapest first).`,
3038
+ // The catalog over-reports: it returns a `:batch` id for models whose
3039
+ // Batch API refuses the job, with nothing in the entry to tell them
3040
+ // apart (#141). Say so before the table, not after it.
3041
+ "Advisory: this is the catalog's list of :batch ids, not a list of working batch endpoints. Some ids are refused at submit " +
3042
+ "(\"does not have a :batch endpoint\"), at no cost. Rows tagged [no batch endpoint …] or [batch OK …] carry what this " +
3043
+ "repository's own submits found; an untagged row has not been tried here.",
2819
3044
  "",
2820
3045
  "id | $/M in | $/M out | ctx | max out | structured | coding idx",
2821
3046
  ];
@@ -2835,12 +3060,21 @@ export function modelsText(entries, opts) {
2835
3060
  const out = entry.maxCompletionTokens ? `${(entry.maxCompletionTokens / 1024).toFixed(0)}k` : "?";
2836
3061
  const tag = entry.id === opts.defaultModel ? " (default)" : "";
2837
3062
  const exp = entry.expirationDate ? " [deprecated]" : "";
2838
- lines.push(`${entry.id}${tag}${exp} | ${entry.inputPerM.toFixed(3)} | ${entry.outputPerM.toFixed(3)} | ${ctx} | ${out} | ${structured} | ${coding}`);
3063
+ const record = endpoints[entry.id];
3064
+ const seen = record
3065
+ ? record.status === "rejected"
3066
+ ? ` [no batch endpoint, refused ${record.at.slice(0, 10)}]`
3067
+ : ` [batch OK ${record.at.slice(0, 10)}]`
3068
+ : "";
3069
+ lines.push(`${entry.id}${tag}${exp}${seen} | ${entry.inputPerM.toFixed(3)} | ${entry.outputPerM.toFixed(3)} | ${ctx} | ${out} | ${structured} | ${coding}`);
2839
3070
  }
2840
3071
  if (opts.benchmarks?.meta.as_of) {
2841
3072
  lines.push("", `Benchmarks: Artificial Analysis coding index (as of ${String(opts.benchmarks.meta.as_of)}).`);
2842
3073
  }
2843
- lines.push("", "Set the batch model in .codecarto/broadside/config.yaml (model key). Higher coding index ≠ better scout: precision, context, and structured-output support matter most here.");
3074
+ lines.push("", "Choose with the model parameter (--model= on Pi) for one run, lens_models (--lens-model=LENS:ID) per lens, or the model key in " +
3075
+ ".codecarto/broadside/config.yaml for the repository. Higher coding index ≠ better scout: precision, context, structured-output " +
3076
+ "support, and whether the model spends its output budget reasoning (see reasoning: in config.yaml) matter most here. " +
3077
+ "A refused submit costs nothing, so probe an untried model on one lens first.");
2844
3078
  return lines.join("\n");
2845
3079
  }
2846
3080
  /** One line of a batch's error field, whatever shape the provider gave it. */
@@ -2853,6 +3087,15 @@ function describeBatchError(error) {
2853
3087
  const message = error.message;
2854
3088
  if (typeof message === "string" && message)
2855
3089
  return message.slice(0, 300);
3090
+ // OpenRouter wraps a submit refusal as `{ error: { message } }`.
3091
+ const nested = error.error;
3092
+ if (nested && typeof nested === "object") {
3093
+ const inner = nested.message;
3094
+ if (typeof inner === "string" && inner)
3095
+ return inner.slice(0, 300);
3096
+ }
3097
+ if (typeof nested === "string" && nested)
3098
+ return nested.slice(0, 300);
2856
3099
  try {
2857
3100
  return JSON.stringify(error).slice(0, 300);
2858
3101
  }
@@ -2862,6 +3105,30 @@ function describeBatchError(error) {
2862
3105
  }
2863
3106
  return String(error);
2864
3107
  }
3108
+ /**
3109
+ * A provider refusal plus what to do about it, for the two refusals a batch
3110
+ * run meets in practice and cannot fix by itself (#141):
3111
+ *
3112
+ * - `Model '<id>' does not have a :batch endpoint.` — the catalog advertises a
3113
+ * `:batch` id that OpenRouter runs no batch endpoint for. Nothing in the
3114
+ * catalog distinguishes these; the `models` action marks ids this
3115
+ * repository has seen refused.
3116
+ * - `job-submission-count … in use: 16, quota: 16` — the per-account limit
3117
+ * on concurrent batch jobs. Broad-Side submits one job per lens, so a few
3118
+ * runs in flight on the same key fill it; the refusal costs nothing.
3119
+ */
3120
+ export function explainBatchError(error) {
3121
+ const message = describeBatchError(error);
3122
+ if (!message)
3123
+ return null;
3124
+ if (NO_BATCH_ENDPOINT_RE.test(message)) {
3125
+ return `${message} — the catalog lists this id, but OpenRouter runs no batch endpoint for it. Nothing was charged; pick another model (the models action marks ids this repository has seen refused).`;
3126
+ }
3127
+ if (BATCH_QUOTA_RE.test(message)) {
3128
+ return `${message} — OpenRouter's per-account limit on concurrent batch jobs is full. Broad-Side submits one job per lens, so a few runs in flight on this key (in any repository) fill it. Nothing was charged; collect or wait out the runs in flight, then re-submit.`;
3129
+ }
3130
+ return message;
3131
+ }
2865
3132
  export function collectResultText(result) {
2866
3133
  const lines = [
2867
3134
  `Broad-Side run ${result.runId}: ${result.status}`,