codecartographer-pi 0.19.5 → 0.20.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 (46) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/broadside/SKILL.md +14 -0
  3. package/.codecarto/broadside/config.yaml +18 -0
  4. package/.codecarto/templates/gitignore +55 -0
  5. package/.codecarto/workflow/VALIDATE.md +2 -1
  6. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  7. package/README.md +10 -6
  8. package/dist/core/amendment.js +28 -23
  9. package/dist/core/broadside.d.ts +72 -2
  10. package/dist/core/broadside.js +351 -68
  11. package/dist/core/completion.js +95 -26
  12. package/dist/core/dashboard-writer.d.ts +8 -0
  13. package/dist/core/dashboard-writer.js +159 -0
  14. package/dist/core/index.d.ts +2 -0
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/library.js +115 -107
  17. package/dist/core/orchestrator-config.d.ts +32 -7
  18. package/dist/core/orchestrator-config.js +124 -44
  19. package/dist/core/pipeline.d.ts +37 -0
  20. package/dist/core/pipeline.js +80 -10
  21. package/dist/core/prompts.d.ts +20 -0
  22. package/dist/core/prompts.js +43 -10
  23. package/dist/core/secrets.d.ts +16 -0
  24. package/dist/core/secrets.js +98 -0
  25. package/dist/core/status.d.ts +30 -2
  26. package/dist/core/status.js +54 -8
  27. package/dist/core/synthesis.js +5 -2
  28. package/dist/core/usage.d.ts +8 -0
  29. package/dist/core/usage.js +35 -7
  30. package/dist/core/utils.d.ts +32 -5
  31. package/dist/core/utils.js +81 -19
  32. package/dist/core/workspace.d.ts +99 -18
  33. package/dist/core/workspace.js +275 -36
  34. package/dist/core/yaml.js +173 -14
  35. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  36. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  37. package/dist/extensions/codecarto/agent-runner.js +27 -9
  38. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  39. package/dist/extensions/codecarto/auto-runner.js +10 -6
  40. package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
  41. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  42. package/dist/extensions/codecarto/dashboard-writer.js +5 -157
  43. package/dist/extensions/codecarto/index.js +73 -21
  44. package/dist/extensions/codecarto/phase-compaction.js +7 -7
  45. package/dist/mcp-server/server.js +111 -50
  46. package/package.json +3 -2
@@ -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 { mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
34
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
35
35
  import { execFile } from "node:child_process";
36
36
  import { promisify } from "node:util";
37
37
  import { join, relative } from "node:path";
38
- import { pathExists, sleep } from "./utils.js";
38
+ import { atomicWriteFile, pathExists, sleep } from "./utils.js";
39
+ import { describeRedactions, isSecretFile, redactSecrets } from "./secrets.js";
39
40
  import { acquireLock } from "./status.js";
40
41
  import { loadYamlFile } from "./yaml.js";
41
42
  import { packagedWorkspaceDir } from "./workspace.js";
@@ -102,6 +103,23 @@ export const BROADSIDE_MIN_REASONING_TOKENS = 512;
102
103
  export function defaultReasoningFor(maxTokens) {
103
104
  return { max_tokens: Math.max(BROADSIDE_MIN_REASONING_TOKENS, Math.floor(maxTokens * BROADSIDE_REASONING_BUDGET_FRACTION)) };
104
105
  }
106
+ /**
107
+ * OpenRouter rejected the API key (HTTP 401/403). Thrown from the catalog
108
+ * lookup rather than swallowed into "could not price" or a silent built-in
109
+ * fallback: a run that cannot authenticate cannot submit either, and the
110
+ * message that reaches the user has to say so (#251).
111
+ */
112
+ export class BroadsideAuthError extends Error {
113
+ httpStatus;
114
+ detail;
115
+ constructor(httpStatus, detail) {
116
+ super(`OpenRouter rejected the API key (HTTP ${httpStatus}${detail ? `: ${detail}` : ""}). ` +
117
+ "Check OPENROUTER_API_KEY, the api_key parameter, or api_key in .codecarto/broadside/config.yaml. Nothing was submitted.");
118
+ this.name = "BroadsideAuthError";
119
+ this.httpStatus = httpStatus;
120
+ this.detail = detail;
121
+ }
122
+ }
105
123
  /** Thrown when a confirm hook declines a run. Nothing was submitted. */
106
124
  export class BroadsideCancelledError extends Error {
107
125
  constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
@@ -799,14 +817,23 @@ const SKIP_FILE_EXTENSIONS = new Set([
799
817
  ".model",
800
818
  ".bpe",
801
819
  ]);
820
+ /**
821
+ * Manifest files and the languages each one can mean. `package.json` covers
822
+ * both TypeScript and JavaScript; which of the two a repository is comes from
823
+ * counting its source files, not from the manifest.
824
+ */
802
825
  const MANIFEST_CANDIDATES = [
803
- ["go.mod", "go"],
804
- ["package.json", "typescript"],
805
- ["Cargo.toml", "rust"],
806
- ["pyproject.toml", "python"],
807
- ["setup.py", "python"],
808
- ["requirements.txt", "python"],
826
+ ["go.mod", ["go"]],
827
+ ["package.json", ["typescript", "javascript"]],
828
+ ["Cargo.toml", ["rust"]],
829
+ ["pyproject.toml", ["python"]],
830
+ ["setup.py", ["python"]],
831
+ ["requirements.txt", ["python"]],
809
832
  ];
833
+ /** The languages Broad-Side can scan; anything else is refused at submit. */
834
+ export const BROADSIDE_LANGUAGES = ["go", "python", "rust", "typescript", "javascript"];
835
+ /** Chars of the entry-point file and the manifest that ride in the architecture prompt (#249). */
836
+ const REPO_INFO_FILE_CAP = 20_000;
810
837
  const SOURCE_SPECS = {
811
838
  go: { glob: "**/*.go", exts: [".go"] },
812
839
  python: { glob: "**/*.py", exts: [".py"] },
@@ -814,16 +841,26 @@ const SOURCE_SPECS = {
814
841
  typescript: { glob: "**/*.ts", exts: [".ts", ".tsx"] },
815
842
  javascript: { glob: "**/*.js", exts: [".js", ".jsx"] },
816
843
  };
844
+ /**
845
+ * The files a run scans, and where they came from. Contents are always read
846
+ * from the working tree, so the list is the working tree's too: tracked files
847
+ * plus untracked ones git does not ignore, minus files deleted on disk. The
848
+ * list used to come from `git ls-tree HEAD`, so a run mixed the committed
849
+ * file list with uncommitted contents and never saw an untracked file (#248).
850
+ * A target that is not a git repository gets a bounded walk.
851
+ */
817
852
  async function listRepoFiles(targetDir) {
818
- // git ls-tree is the fast path; fall back to a bounded walk for non-git trees.
819
853
  try {
820
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "ls-tree", "-r", "--name-only", "HEAD"], {
854
+ const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024 });
855
+ const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
821
856
  maxBuffer: 64 * 1024 * 1024,
822
857
  });
823
- return stdout.split("\n").filter(Boolean);
858
+ const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
859
+ const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
860
+ return { files, snapshot: "working-tree" };
824
861
  }
825
862
  catch {
826
- return walkFiles(targetDir, targetDir, 0, 30_000);
863
+ return { files: await walkFiles(targetDir, targetDir, 0, 30_000), snapshot: "walk" };
827
864
  }
828
865
  }
829
866
  async function gitHead(targetDir) {
@@ -892,25 +929,65 @@ async function walkFiles(rootDir, dir, depth, remaining) {
892
929
  }
893
930
  return out;
894
931
  }
895
- function detectLanguage(fileCounts, manifestPath) {
896
- if (manifestPath) {
897
- for (const [candidate, lang] of MANIFEST_CANDIDATES) {
898
- if (manifestPath === candidate)
899
- return lang;
932
+ function sourceFileCount(language, fileCounts) {
933
+ return (SOURCE_SPECS[language]?.exts ?? []).reduce((sum, ext) => sum + (fileCounts[ext] ?? 0), 0);
934
+ }
935
+ /**
936
+ * The language the lenses scan as. The manifests present name the candidates
937
+ * (all of them, not the first one found: a Python service with a
938
+ * `package.json` for its docs tooling is not a TypeScript repository), and
939
+ * among candidates the one with the most source files wins; without a
940
+ * manifest, the language with the most source files; without any source
941
+ * file, `unknown` — which submit refuses rather than scanning nothing and
942
+ * paying for it (#250). Ties keep manifest order.
943
+ */
944
+ function detectLanguage(fileCounts, manifestPaths) {
945
+ const candidates = [];
946
+ for (const [candidate, languages] of MANIFEST_CANDIDATES) {
947
+ if (!manifestPaths.includes(candidate))
948
+ continue;
949
+ for (const language of languages)
950
+ if (!candidates.includes(language))
951
+ candidates.push(language);
952
+ }
953
+ const pool = candidates.length > 0 ? candidates : [...BROADSIDE_LANGUAGES];
954
+ let best = null;
955
+ let bestCount = -1;
956
+ for (const language of pool) {
957
+ const count = sourceFileCount(language, fileCounts);
958
+ if (count > bestCount) {
959
+ best = language;
960
+ bestCount = count;
900
961
  }
901
962
  }
902
- const counts = {
903
- go: fileCounts[".go"] ?? 0,
904
- python: fileCounts[".py"] ?? 0,
905
- rust: fileCounts[".rs"] ?? 0,
906
- typescript: (fileCounts[".ts"] ?? 0) + (fileCounts[".tsx"] ?? 0),
907
- javascript: fileCounts[".js"] ?? 0,
908
- };
909
- const best = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
910
- return best && best[1] > 0 ? best[0] : "unknown";
963
+ if (bestCount > 0)
964
+ return best;
965
+ // A manifest with no source files behind it still names the language;
966
+ // submit reports the empty count. No manifest and no source: unknown.
967
+ return candidates[0] ?? "unknown";
911
968
  }
912
- export async function collectRepoInfo(targetDir) {
913
- const allFiles = await listRepoFiles(targetDir);
969
+ /** Cut a file that rides whole in a prompt down to the cap, saying so (#249). */
970
+ function capForPrompt(content, cap) {
971
+ if (content.length <= cap)
972
+ return content;
973
+ return `${content.slice(0, cap)}\n… [truncated: ${cap.toLocaleString()} of ${content.length.toLocaleString()} chars shown]\n`;
974
+ }
975
+ export async function collectRepoInfo(targetDir, opts = {}) {
976
+ const redact = opts.redact ?? true;
977
+ const { files: allFiles, snapshot } = await listRepoFiles(targetDir);
978
+ // Named credential stores are out of every lens (isSlurpable); listed here
979
+ // so the submit report can say so.
980
+ const secretFilesSkipped = allFiles.filter((path) => isSecretFile(path)).sort();
981
+ let redactedValues = 0;
982
+ // The entry point, manifest, and README ride in the architecture prompt
983
+ // as text, so they get the same pass the slices do (#252).
984
+ const clean = (text) => {
985
+ if (!redact)
986
+ return text;
987
+ const redaction = redactSecrets(text);
988
+ redactedValues += redaction.count;
989
+ return redaction.text;
990
+ };
914
991
  const fileCounts = {};
915
992
  for (const f of allFiles) {
916
993
  const slash = f.lastIndexOf("/");
@@ -923,25 +1000,37 @@ export async function collectRepoInfo(targetDir) {
923
1000
  for (const [ext, n] of Object.entries(fileCounts).sort((a, b) => b[1] - a[1])) {
924
1001
  sortedCounts[ext] = n;
925
1002
  }
926
- let manifest = null;
1003
+ // Every manifest present counts toward language detection; the first one
1004
+ // found is the one the architecture prompt shows.
1005
+ const manifestPaths = [];
927
1006
  for (const [candidate] of MANIFEST_CANDIDATES) {
928
- const p = join(targetDir, candidate);
929
- if (await pathExists(p)) {
930
- try {
931
- manifest = { path: candidate, content: await readFile(p, "utf8") };
932
- }
933
- catch {
934
- manifest = null;
935
- }
936
- break;
1007
+ if (await pathExists(join(targetDir, candidate)))
1008
+ manifestPaths.push(candidate);
1009
+ }
1010
+ const language = detectLanguage(sortedCounts, manifestPaths);
1011
+ // Show the manifest that belongs to the detected language when there is
1012
+ // one, so a polyglot repo's prompt does not open with the other stack's file.
1013
+ const manifestPath = manifestPaths.find((path) => MANIFEST_CANDIDATES.find(([candidate]) => candidate === path)?.[1].includes(language))
1014
+ ?? manifestPaths[0]
1015
+ ?? null;
1016
+ let manifest = null;
1017
+ if (manifestPath) {
1018
+ try {
1019
+ manifest = { path: manifestPath, content: capForPrompt(clean(await readFile(join(targetDir, manifestPath), "utf8")), REPO_INFO_FILE_CAP) };
1020
+ }
1021
+ catch {
1022
+ manifest = null;
937
1023
  }
938
1024
  }
1025
+ // Read whole and unbounded before, and then estimated at a flat 6,000
1026
+ // chars: a large entry point shipped in full while the cap was checked
1027
+ // against a number that had nothing to do with it (#249).
939
1028
  let mainFile = "";
940
- for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts"]) {
1029
+ for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts", "src/index.js", "index.js"]) {
941
1030
  const p = join(targetDir, candidate);
942
1031
  if (await pathExists(p)) {
943
1032
  try {
944
- mainFile = await readFile(p, "utf8");
1033
+ mainFile = capForPrompt(clean(await readFile(p, "utf8")), REPO_INFO_FILE_CAP);
945
1034
  }
946
1035
  catch {
947
1036
  mainFile = "";
@@ -953,16 +1042,18 @@ export async function collectRepoInfo(targetDir) {
953
1042
  const readmePath = join(targetDir, "README.md");
954
1043
  if (await pathExists(readmePath)) {
955
1044
  try {
956
- readmeFirst = (await readFile(readmePath, "utf8")).slice(0, 4000);
1045
+ readmeFirst = clean((await readFile(readmePath, "utf8")).slice(0, 4000));
957
1046
  }
958
1047
  catch {
959
1048
  readmeFirst = "";
960
1049
  }
961
1050
  }
962
1051
  const fileTree = buildFileTree(allFiles);
963
- const language = detectLanguage(sortedCounts, manifest?.path ?? null);
964
- const sourceSpec = SOURCE_SPECS[language] ?? SOURCE_SPECS.go;
1052
+ // An unknown language used to fall through to Go's globs, so the code
1053
+ // lenses matched nothing and the run paid for empty batches (#250).
1054
+ const sourceSpec = SOURCE_SPECS[language] ?? { glob: "", exts: [] };
965
1055
  const name = targetDir.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
1056
+ const sourceFiles = allFiles.filter((path) => isSlurpable(path) && sourceSpec.exts.some((ext) => path.toLowerCase().endsWith(ext))).length;
966
1057
  return {
967
1058
  name,
968
1059
  path: targetDir,
@@ -974,6 +1065,10 @@ export async function collectRepoInfo(targetDir) {
974
1065
  fileCounts: sortedCounts,
975
1066
  sourceGlob: sourceSpec.glob,
976
1067
  sourceExts: sourceSpec.exts,
1068
+ sourceFileCount: sourceFiles,
1069
+ snapshot,
1070
+ secretFilesSkipped,
1071
+ redactedValues,
977
1072
  };
978
1073
  }
979
1074
  function buildFileTree(allFiles, maxDepth = 3, maxLines = 200) {
@@ -1034,6 +1129,9 @@ function matchesAnyGlob(path, globs) {
1034
1129
  return false;
1035
1130
  }
1036
1131
  function isSlurpable(relPath) {
1132
+ // A credential store is never a lens input, whatever its globs say (#252).
1133
+ if (isSecretFile(relPath))
1134
+ return false;
1037
1135
  const segments = relPath.split("/");
1038
1136
  for (const seg of segments) {
1039
1137
  if (SKIP_DIR_NAMES.has(seg))
@@ -1070,7 +1168,7 @@ function resolveSliceMode(lens, files, totalChars) {
1070
1168
  return totalChars > lens.maxChars ? "directory" : "none";
1071
1169
  }
1072
1170
  function collectLensFiles(allFiles, lens, info) {
1073
- const globs = lens.globsFor(info);
1171
+ const globs = lens.globsFor(info).filter(Boolean);
1074
1172
  if (globs.length === 0)
1075
1173
  return [];
1076
1174
  const out = [];
@@ -1085,13 +1183,15 @@ function collectLensFiles(allFiles, lens, info) {
1085
1183
  }
1086
1184
  return out;
1087
1185
  }
1088
- async function slurpFileList(targetDir, files, maxChars) {
1186
+ async function slurpFileList(targetDir, files, maxChars, redact = true) {
1089
1187
  const slices = [];
1090
1188
  let currentModule = "";
1091
1189
  let parts = [];
1092
1190
  let running = 0;
1093
1191
  let fileCount = 0;
1094
1192
  let filePaths = [];
1193
+ let redactedValues = 0;
1194
+ let redactedFiles = [];
1095
1195
  const flush = () => {
1096
1196
  if (parts.length === 0)
1097
1197
  return;
@@ -1101,20 +1201,38 @@ async function slurpFileList(targetDir, files, maxChars) {
1101
1201
  fileCount,
1102
1202
  chars: running,
1103
1203
  files: filePaths,
1204
+ redactedValues,
1205
+ redactedFiles,
1104
1206
  });
1105
1207
  parts = [];
1106
1208
  running = 0;
1107
1209
  fileCount = 0;
1108
1210
  filePaths = [];
1211
+ redactedValues = 0;
1212
+ redactedFiles = [];
1109
1213
  };
1110
1214
  for (const file of files) {
1111
1215
  let content = "";
1112
1216
  try {
1113
1217
  content = await readFile(join(targetDir, file.relPath), "utf8");
1114
1218
  }
1115
- catch {
1219
+ catch (error) {
1220
+ // The listing is the working tree's, so this is a race with a
1221
+ // concurrent delete rather than a listed-but-deleted file; skip it.
1222
+ if (error.code === "ENOENT")
1223
+ continue;
1116
1224
  content = "[BINARY or UNREADABLE]";
1117
1225
  }
1226
+ if (redact) {
1227
+ // Before the slice is built, so the count and the chars the estimate
1228
+ // sees are of what is actually sent (#252).
1229
+ const redaction = redactSecrets(content);
1230
+ if (redaction.count > 0) {
1231
+ content = redaction.text;
1232
+ redactedValues += redaction.count;
1233
+ redactedFiles.push(file.relPath);
1234
+ }
1235
+ }
1118
1236
  const block = `=== ${file.relPath} ===\n${content}\n`;
1119
1237
  if (file.moduleName !== currentModule && parts.length > 0) {
1120
1238
  flush();
@@ -1134,12 +1252,13 @@ async function slurpFileList(targetDir, files, maxChars) {
1134
1252
  flush();
1135
1253
  return slices;
1136
1254
  }
1137
- export async function gatherSlices(targetDir, lens, info) {
1255
+ export async function gatherSlices(targetDir, lens, info, opts = {}) {
1256
+ const redact = opts.redact ?? true;
1138
1257
  if (lens.sliceBy === "none" && lens.globsFor(info).length === 0) {
1139
1258
  // Repo-info lens (architecture): the prompt is built from info alone.
1140
1259
  return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
1141
1260
  }
1142
- const allFiles = await listRepoFiles(targetDir);
1261
+ const { files: allFiles } = await listRepoFiles(targetDir);
1143
1262
  const files = collectLensFiles(allFiles, lens, info);
1144
1263
  const totalChars = await sumFileSizes(targetDir, files);
1145
1264
  const mode = resolveSliceMode(lens, files, totalChars);
@@ -1147,9 +1266,9 @@ export async function gatherSlices(targetDir, lens, info) {
1147
1266
  // Whole-repo slice: one module named after the repo, so a small
1148
1267
  // repo produces a single request instead of one per directory.
1149
1268
  const single = files.map((f) => ({ ...f, moduleName: info.name }));
1150
- return slurpFileList(targetDir, single, lens.maxChars);
1269
+ return slurpFileList(targetDir, single, lens.maxChars, redact);
1151
1270
  }
1152
- return slurpFileList(targetDir, files, lens.maxChars);
1271
+ return slurpFileList(targetDir, files, lens.maxChars, redact);
1153
1272
  }
1154
1273
  async function sumFileSizes(targetDir, files) {
1155
1274
  let total = 0;
@@ -1199,7 +1318,12 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
1199
1318
  * estimate covers slice content only, which is what the old signature did.
1200
1319
  */
1201
1320
  export function estimateCost(lens, slices, pricing, maxTokensOverride, info) {
1202
- const sliceChars = slices.reduce((sum, s) => sum + (lens.maxChars === 0 ? 6000 : s.chars), 0);
1321
+ // With repo info, size each request from the user prompt that would be
1322
+ // sent, which is what the architecture lens is made of: it used to be
1323
+ // estimated at a flat 6,000 chars while the entry point, manifest, README
1324
+ // excerpt and file tree it carries ran to whatever they ran to (#249).
1325
+ // Without info, the slice content alone is what the old signature covered.
1326
+ const sliceChars = slices.reduce((sum, s) => sum + (info ? lens.userPrompt(info, s.content, s.moduleName).length : lens.maxChars === 0 ? 6000 : s.chars), 0);
1203
1327
  // The system prompt and the response schema ride on every request, so they
1204
1328
  // are paid once per slice rather than once per lens.
1205
1329
  const perRequestOverhead = info
@@ -1279,9 +1403,7 @@ export async function saveBroadsideState(broadsideDir, state) {
1279
1403
  }
1280
1404
  /** Serialize through a temp file so a crash mid-write cannot truncate state.json. */
1281
1405
  async function writeBroadsideStateFile(statePath, state) {
1282
- const tempPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
1283
- await writeFile(tempPath, `${JSON.stringify(state, null, "\t")}\n`, "utf8");
1284
- await rename(tempPath, statePath);
1406
+ await atomicWriteFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
1285
1407
  }
1286
1408
  /**
1287
1409
  * Read-modify-write `state.json` under a lock.
@@ -1395,6 +1517,7 @@ export async function loadBroadsideConfig(broadsideDir) {
1395
1517
  includeSynthesis: flag("include_synthesis", true),
1396
1518
  includeTriage: flag("include_triage", true),
1397
1519
  waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
1520
+ redactSecrets: flag("redact_secrets", true),
1398
1521
  };
1399
1522
  }
1400
1523
  async function readCatalogCache(broadsideDir) {
@@ -1493,20 +1616,37 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
1493
1616
  if (cached && Date.now() - new Date(cache.fetched_at).getTime() < BROADSIDE_CATALOG_CACHE_TTL_MS) {
1494
1617
  return { model, source: "cache", entry: cached };
1495
1618
  }
1619
+ // What went wrong when the live lookup produced nothing, for the error
1620
+ // below: a 401 and a dead network used to read the same — "could not
1621
+ // resolve per-token pricing" — or, for the default model, nothing at all.
1496
1622
  let live = null;
1623
+ let catalogFailure = null;
1497
1624
  try {
1498
1625
  const resp = await fetcher(BROADSIDE_MODELS_URL, {
1499
1626
  method: "GET",
1500
1627
  headers: { Authorization: `Bearer ${apiKey}` },
1501
1628
  signal: AbortSignal.timeout(30_000),
1502
1629
  });
1503
- const data = (await resp.json());
1504
- const hit = (data.data ?? []).find((m) => String(m.id) === model);
1505
- if (hit)
1506
- live = parseCatalogEntry(hit);
1630
+ if (resp.status === 401 || resp.status === 403) {
1631
+ throw new BroadsideAuthError(resp.status, await responseDetail(resp));
1632
+ }
1633
+ if (resp.ok === false) {
1634
+ catalogFailure = `the model catalog request failed (HTTP ${resp.status}${await responseDetail(resp).then((d) => (d ? `: ${d}` : ""))})`;
1635
+ }
1636
+ else {
1637
+ const data = (await resp.json());
1638
+ const hit = (data.data ?? []).find((m) => String(m.id) === model);
1639
+ if (hit)
1640
+ live = parseCatalogEntry(hit);
1641
+ else
1642
+ catalogFailure = `the model catalog has no entry for "${model}"`;
1643
+ }
1507
1644
  }
1508
- catch {
1645
+ catch (error) {
1646
+ if (error instanceof BroadsideAuthError)
1647
+ throw error;
1509
1648
  live = null;
1649
+ catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
1510
1650
  }
1511
1651
  if (live) {
1512
1652
  const updated = {
@@ -1523,10 +1663,37 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
1523
1663
  const builtIn = builtInCatalogEntry(model);
1524
1664
  if (builtIn)
1525
1665
  return { model, source: "built-in", entry: builtIn };
1526
- throw new Error(`Could not resolve per-token pricing for batch model "${model}". ` +
1666
+ throw new Error(`Could not resolve per-token pricing for batch model "${model}": ${catalogFailure ?? "no catalog entry"}. ` +
1527
1667
  "Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
1528
1668
  "(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
1529
1669
  }
1670
+ /** A short, safe excerpt of an error response body for a message. */
1671
+ async function responseDetail(resp) {
1672
+ try {
1673
+ if (typeof resp.text === "function") {
1674
+ const text = (await resp.text()).trim();
1675
+ try {
1676
+ const parsed = JSON.parse(text);
1677
+ const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
1678
+ if (typeof message === "string" && message)
1679
+ return message.slice(0, 200);
1680
+ }
1681
+ catch {
1682
+ // not JSON; fall through to the raw excerpt
1683
+ }
1684
+ return text.replace(/\s+/g, " ").slice(0, 200);
1685
+ }
1686
+ if (typeof resp.json === "function") {
1687
+ const parsed = (await resp.json());
1688
+ const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
1689
+ return typeof message === "string" ? message.slice(0, 200) : "";
1690
+ }
1691
+ }
1692
+ catch {
1693
+ // an unreadable body adds nothing to the message
1694
+ }
1695
+ return "";
1696
+ }
1530
1697
  export async function resolveModelPricing(broadsideDir, config, model, apiKey, fetcher = fetch) {
1531
1698
  const { source, entry } = await resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher);
1532
1699
  if (!entry)
@@ -1620,7 +1787,17 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
1620
1787
  headers: { Authorization: `Bearer ${apiKey}` },
1621
1788
  signal: AbortSignal.timeout(30_000),
1622
1789
  });
1623
- const data = (await resp.json());
1790
+ let data;
1791
+ try {
1792
+ data = (await resp.json());
1793
+ }
1794
+ catch (error) {
1795
+ // A gateway error page is not JSON. It used to throw out of here and
1796
+ // be retried as if the network were down; keep the status instead.
1797
+ data = { error: `non-JSON response (${error instanceof Error ? error.message : String(error)})` };
1798
+ }
1799
+ if (!data || typeof data !== "object")
1800
+ data = { error: "empty response" };
1624
1801
  // Surface the HTTP status so the poller can bail fast on auth expiry
1625
1802
  // instead of retrying a dead key for the whole budget.
1626
1803
  data.http_status = resp.status;
@@ -1638,14 +1815,26 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
1638
1815
  const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
1639
1816
  const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
1640
1817
  const fetcher = opts.fetcher ?? fetch;
1818
+ // A poll that runs out of budget without one good response is not a slow
1819
+ // batch. The last thing that went wrong rides on the timeout so the report
1820
+ // can tell a dead network or a failing gateway from a batch still running.
1821
+ let lastError = null;
1822
+ let sawBatch = false;
1823
+ const timedOut = () => ({
1824
+ id: batchId,
1825
+ status: "timeout",
1826
+ ...(lastError && !sawBatch && { error: `no successful poll response; last error: ${lastError}` }),
1827
+ ...(lastError && sawBatch && { last_error: lastError }),
1828
+ });
1641
1829
  for (;;) {
1642
1830
  let batch;
1643
1831
  try {
1644
1832
  batch = await fetchBatch(batchId, apiKey, fetcher);
1645
1833
  }
1646
- catch {
1834
+ catch (error) {
1835
+ lastError = `fetch failed (${error instanceof Error ? error.message : String(error)})`;
1647
1836
  if (Date.now() >= deadline)
1648
- return { id: batchId, status: "timeout" };
1837
+ return timedOut();
1649
1838
  await sleep(intervalMs);
1650
1839
  continue;
1651
1840
  }
@@ -1653,13 +1842,23 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
1653
1842
  if (httpStatus === 401 || httpStatus === 403) {
1654
1843
  return { id: batchId, status: "auth-failed", error: batch.error ?? batch };
1655
1844
  }
1845
+ if (httpStatus >= 400) {
1846
+ // A gateway or server error: retry within the budget, remembered.
1847
+ const detail = typeof batch.error === "string" ? batch.error : JSON.stringify(batch.error ?? "");
1848
+ lastError = `HTTP ${httpStatus}${detail ? ` (${detail.slice(0, 200)})` : ""}`;
1849
+ if (Date.now() >= deadline)
1850
+ return timedOut();
1851
+ await sleep(intervalMs);
1852
+ continue;
1853
+ }
1854
+ sawBatch = true;
1656
1855
  const status = String(batch.status ?? "unknown");
1657
1856
  const counts = (batch.request_counts ?? {});
1658
1857
  opts.onStatus?.(status, counts);
1659
1858
  if (status === "completed" || BROADSIDE_DEAD_BATCH_STATUSES.includes(status))
1660
1859
  return batch;
1661
1860
  if (Date.now() >= deadline)
1662
- return { id: batchId, status: "timeout" };
1861
+ return timedOut();
1663
1862
  await sleep(intervalMs);
1664
1863
  }
1665
1864
  }
@@ -1686,7 +1885,6 @@ export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
1686
1885
  }
1687
1886
  // ---------- run orchestration ----------
1688
1887
  export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1689
- const info = await collectRepoInfo(cwd);
1690
1888
  const lensIds = opts.lenses ?? BROADSIDE_LENS_IDS;
1691
1889
  const broadsideDir = broadsideDirFor(cwd);
1692
1890
  const model = opts.model ?? BROADSIDE_MODEL;
@@ -1695,6 +1893,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1695
1893
  // structured-output support that not all batch models offer. Lenses may run
1696
1894
  // on different models (config `lens_models`), so each one is pre-flighted.
1697
1895
  const config = await loadBroadsideConfig(broadsideDir);
1896
+ const redact = config.redactSecrets;
1897
+ const info = await collectRepoInfo(cwd, { redact });
1898
+ // Before pricing, before the network, before any state write: a run on a
1899
+ // language the lenses cannot scan used to submit empty batches and pay for
1900
+ // them (#250).
1901
+ if (info.language === "unknown") {
1902
+ throw new Error(`Broad-Side could not tell what language this repository is: no ${MANIFEST_CANDIDATES.map(([candidate]) => candidate).join(", ")} ` +
1903
+ `and no source files in a language the lenses can scan (${BROADSIDE_LANGUAGES.join(", ")}). Nothing was submitted.`);
1904
+ }
1905
+ if (info.sourceFileCount === 0) {
1906
+ throw new Error(`Broad-Side found no ${info.language} source files to scan (detected from ${info.manifest?.path ?? "the file counts"}; ` +
1907
+ `the lenses look for ${info.sourceExts.join(", ")}). Nothing was submitted.`);
1908
+ }
1698
1909
  const modelForLens = (lensId) => config.lensModels[lensId] ?? model;
1699
1910
  const resolved = new Map();
1700
1911
  for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
@@ -1760,9 +1971,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1760
1971
  let estimatedOutputTokens = 0;
1761
1972
  let estimatedTotalCost = 0;
1762
1973
  const perLensEstimate = [];
1974
+ // What the redaction pass did across every lens's slices, for the run
1975
+ // record and the report: a value in a file shared by two lenses counts
1976
+ // once per lens it was sent in, files once each.
1977
+ let redactedValues = info.redactedValues;
1978
+ const redactedFiles = new Set();
1763
1979
  for (const lensId of lensIds) {
1764
1980
  const lens = getLens(lensId);
1765
- let slices = await gatherSlices(cwd, lens, info);
1981
+ let slices = await gatherSlices(cwd, lens, info, { redact });
1982
+ for (const slice of slices) {
1983
+ redactedValues += slice.redactedValues ?? 0;
1984
+ for (const file of slice.redactedFiles ?? [])
1985
+ redactedFiles.add(file);
1986
+ }
1766
1987
  if (changed) {
1767
1988
  // Repo-info slices (empty files, e.g. architecture) always run;
1768
1989
  // file-backed slices run only when one of their files changed.
@@ -1839,6 +2060,14 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1839
2060
  sourceHead,
1840
2061
  sourceDirty,
1841
2062
  baseHead,
2063
+ snapshot: info.snapshot,
2064
+ language: info.language,
2065
+ redaction: {
2066
+ enabled: redact,
2067
+ values: redactedValues,
2068
+ files: redactedFiles.size,
2069
+ skippedFiles: info.secretFilesSkipped.length,
2070
+ },
1842
2071
  };
1843
2072
  state.runs.push(run);
1844
2073
  await persistBroadsideRun(broadsideDir, run);
@@ -1917,6 +2146,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1917
2146
  expirationDate: defaultEntry.expirationDate ?? null,
1918
2147
  },
1919
2148
  incremental: incrementalOutcome,
2149
+ repo: {
2150
+ language: info.language,
2151
+ sourceFiles: info.sourceFileCount,
2152
+ snapshot: info.snapshot,
2153
+ sourceHead,
2154
+ sourceDirty,
2155
+ },
2156
+ redaction: {
2157
+ enabled: redact,
2158
+ values: redactedValues,
2159
+ files: redactedFiles.size,
2160
+ skippedFiles: info.secretFilesSkipped,
2161
+ },
1920
2162
  };
1921
2163
  }
1922
2164
  function extractContent(result) {
@@ -2189,7 +2431,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2189
2431
  // indistinguishable in the output from one that was never requested.
2190
2432
  if (batch.error)
2191
2433
  entry.error = batch.error;
2192
- lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount };
2434
+ const error = describeBatchError(batch.error);
2435
+ lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
2193
2436
  }
2194
2437
  await persistBroadsideRun(broadsideDir, run);
2195
2438
  }
@@ -2536,6 +2779,18 @@ export function estimateSubmitText(result, lenses) {
2536
2779
  const override = entry.model ? ` on ${entry.model}` : "";
2537
2780
  lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}`);
2538
2781
  }
2782
+ if (result.repo) {
2783
+ const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
2784
+ const source = result.repo.snapshot === "working-tree" ? `working tree${head}` : "directory walk (not a git repository)";
2785
+ lines.push(`Scanned as ${result.repo.language}: ${result.repo.sourceFiles} source file(s) from the ${source}.`);
2786
+ }
2787
+ if (result.redaction) {
2788
+ const line = result.redaction.enabled
2789
+ ? describeRedactions(result.redaction.values, result.redaction.files, result.redaction.skippedFiles)
2790
+ : "Before upload: secret redaction is OFF (redact_secrets: false in config.yaml); files were sent as they are.";
2791
+ if (line)
2792
+ lines.push(line);
2793
+ }
2539
2794
  const incremental = result.incremental;
2540
2795
  if (incremental?.requested) {
2541
2796
  lines.push(incremental.applied
@@ -2588,6 +2843,25 @@ export function modelsText(entries, opts) {
2588
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.");
2589
2844
  return lines.join("\n");
2590
2845
  }
2846
+ /** One line of a batch's error field, whatever shape the provider gave it. */
2847
+ function describeBatchError(error) {
2848
+ if (error === undefined || error === null || error === "")
2849
+ return null;
2850
+ if (typeof error === "string")
2851
+ return error.slice(0, 300);
2852
+ if (typeof error === "object") {
2853
+ const message = error.message;
2854
+ if (typeof message === "string" && message)
2855
+ return message.slice(0, 300);
2856
+ try {
2857
+ return JSON.stringify(error).slice(0, 300);
2858
+ }
2859
+ catch {
2860
+ return String(error);
2861
+ }
2862
+ }
2863
+ return String(error);
2864
+ }
2591
2865
  export function collectResultText(result) {
2592
2866
  const lines = [
2593
2867
  `Broad-Side run ${result.runId}: ${result.status}`,
@@ -2601,7 +2875,10 @@ export function collectResultText(result) {
2601
2875
  lines.push(` ${lensId}: ${outcome.status}` +
2602
2876
  (outcome.cost !== undefined ? `, $${outcome.cost.toFixed(6)}` : "") +
2603
2877
  (outcome.resultCount !== undefined ? `, ${outcome.resultCount} result(s)` : "") +
2604
- truncation);
2878
+ truncation +
2879
+ // The reason a lens did not complete, when the poll recorded one:
2880
+ // an auth failure or a dead network used to read as a slow batch.
2881
+ (outcome.error ? ` — ${outcome.error}` : ""));
2605
2882
  }
2606
2883
  if (result.retriedCount > 0) {
2607
2884
  lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
@@ -2641,6 +2918,12 @@ export function statusText(state) {
2641
2918
  const lines = [];
2642
2919
  for (const run of [...state.runs].reverse().slice(0, 3)) {
2643
2920
  lines.push(`Run ${run.id} — ${run.status}`);
2921
+ // Recorded since #248; a run from an older version has neither field.
2922
+ if (run.language || run.snapshot) {
2923
+ const head = run.sourceHead ? ` at ${run.sourceHead.slice(0, 8)}${run.sourceDirty ? " (dirty)" : ""}` : "";
2924
+ const source = run.snapshot === "walk" ? "directory walk" : run.snapshot ? `working tree${head}` : "unknown source";
2925
+ lines.push(` scanned as ${run.language ?? "unknown"} from the ${source}`);
2926
+ }
2644
2927
  for (const lensId of BROADSIDE_LENS_IDS) {
2645
2928
  const entry = run.batches[lensId];
2646
2929
  if (!entry)