codecartographer-pi 0.19.6 → 0.21.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 (57) hide show
  1. package/.codecarto/GUIDE.md +2 -2
  2. package/.codecarto/broadside/SKILL.md +21 -3
  3. package/.codecarto/broadside/config.yaml +35 -9
  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 +3 -2
  15. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  16. package/README.md +13 -9
  17. package/dist/core/amendment.js +9 -4
  18. package/dist/core/broadside.d.ts +121 -2
  19. package/dist/core/broadside.js +478 -92
  20. package/dist/core/completion.js +88 -23
  21. package/dist/core/dashboard-writer.d.ts +8 -0
  22. package/dist/core/dashboard-writer.js +159 -0
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/library.js +9 -4
  26. package/dist/core/orchestrator-config.d.ts +32 -7
  27. package/dist/core/orchestrator-config.js +124 -44
  28. package/dist/core/pipeline.d.ts +73 -0
  29. package/dist/core/pipeline.js +134 -10
  30. package/dist/core/prompts.d.ts +20 -0
  31. package/dist/core/prompts.js +53 -13
  32. package/dist/core/secrets.d.ts +16 -0
  33. package/dist/core/secrets.js +98 -0
  34. package/dist/core/status.d.ts +16 -0
  35. package/dist/core/status.js +47 -20
  36. package/dist/core/synthesis.js +5 -2
  37. package/dist/core/utils.d.ts +7 -0
  38. package/dist/core/utils.js +7 -0
  39. package/dist/core/workspace.d.ts +55 -8
  40. package/dist/core/workspace.js +116 -8
  41. package/dist/core/yaml.js +181 -15
  42. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  43. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  44. package/dist/extensions/codecarto/agent-runner.js +27 -9
  45. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  46. package/dist/extensions/codecarto/auto-runner.d.ts +1 -1
  47. package/dist/extensions/codecarto/auto-runner.js +27 -8
  48. package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
  49. package/dist/extensions/codecarto/broadside-flags.js +12 -0
  50. package/dist/extensions/codecarto/dashboard-narrator.js +9 -2
  51. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  52. package/dist/extensions/codecarto/dashboard-writer.js +5 -154
  53. package/dist/extensions/codecarto/index.js +158 -47
  54. package/dist/extensions/codecarto/phase-compaction.js +5 -1
  55. package/dist/mcp-server/server.d.ts +3 -1
  56. package/dist/mcp-server/server.js +205 -77
  57. package/package.json +3 -2
@@ -31,11 +31,13 @@
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";
40
+ import { describeRedactions, isSecretFile, redactSecrets } from "./secrets.js";
39
41
  import { acquireLock } from "./status.js";
40
42
  import { loadYamlFile } from "./yaml.js";
41
43
  import { packagedWorkspaceDir } from "./workspace.js";
@@ -74,6 +76,16 @@ export const BROADSIDE_LENS_IDS = [
74
76
  ];
75
77
  export const BROADSIDE_POLL_INTERVAL_MS = 15_000;
76
78
  export const BROADSIDE_DEFAULT_POLL_BUDGET_MS = 25 * 60 * 1000;
79
+ /**
80
+ * The run expense limit in USD a repository gets before it configures one.
81
+ * Pi asks a human before submitting over the estimate; the MCP surface cannot,
82
+ * and shipped with no limit at all, so a host calling submit with the stock
83
+ * config spent whatever the estimate came to (#231). One dollar covers a
84
+ * six-lens run of a repository this size with room to spare; a larger one
85
+ * raises `max_cost` in config.yaml, passes `max_cost` on the call, or sets it
86
+ * to 0 for no limit.
87
+ */
88
+ export const BROADSIDE_DEFAULT_MAX_COST = 1;
77
89
  /**
78
90
  * The share of a lens's output budget reasoning may spend.
79
91
  *
@@ -102,6 +114,54 @@ export const BROADSIDE_MIN_REASONING_TOKENS = 512;
102
114
  export function defaultReasoningFor(maxTokens) {
103
115
  return { max_tokens: Math.max(BROADSIDE_MIN_REASONING_TOKENS, Math.floor(maxTokens * BROADSIDE_REASONING_BUDGET_FRACTION)) };
104
116
  }
117
+ /**
118
+ * OpenRouter rejected the API key (HTTP 401/403). Thrown from the catalog
119
+ * lookup rather than swallowed into "could not price" or a silent built-in
120
+ * fallback: a run that cannot authenticate cannot submit either, and the
121
+ * message that reaches the user has to say so (#251).
122
+ */
123
+ export class BroadsideAuthError extends Error {
124
+ httpStatus;
125
+ detail;
126
+ constructor(httpStatus, detail) {
127
+ super(`OpenRouter rejected the API key (HTTP ${httpStatus}${detail ? `: ${detail}` : ""}). ` +
128
+ "Check OPENROUTER_API_KEY, the api_key parameter, or api_key in .codecarto/broadside/config.yaml. Nothing was submitted.");
129
+ this.name = "BroadsideAuthError";
130
+ this.httpStatus = httpStatus;
131
+ this.detail = detail;
132
+ }
133
+ }
134
+ /**
135
+ * `broadside/config.yaml` exists but cannot be used. A file that failed to
136
+ * parse used to be treated exactly like an absent one — defaults, including
137
+ * no spend cap and no lens routing, with no message — so a typo removed the
138
+ * user's own guard (#232). Only an absent file yields defaults now.
139
+ */
140
+ export class BroadsideConfigError extends Error {
141
+ path;
142
+ constructor(path, detail) {
143
+ super(`Broad-Side config ${path} ${detail}. Fix or remove the file; nothing runs on defaults while it is unreadable.`);
144
+ this.name = "BroadsideConfigError";
145
+ this.path = path;
146
+ }
147
+ }
148
+ /**
149
+ * `broadside/state.json` exists but cannot be read. It used to be read as
150
+ * empty and the next checkpoint wrote that empty state over it, losing the
151
+ * batch ids of every in-flight, already-paid run (#233). The corrupt file is
152
+ * preserved beside itself and nothing writes over it until someone looks.
153
+ */
154
+ export class BroadsideStateError extends Error {
155
+ path;
156
+ backupPath;
157
+ constructor(path, backupPath, detail) {
158
+ super(`Broad-Side state ${path} ${detail}. A copy is preserved at ${backupPath}; the file is not overwritten. ` +
159
+ "Repair state.json from the copy (each run's batch ids are what collect needs), or move it aside to start fresh.");
160
+ this.name = "BroadsideStateError";
161
+ this.path = path;
162
+ this.backupPath = backupPath;
163
+ }
164
+ }
105
165
  /** Thrown when a confirm hook declines a run. Nothing was submitted. */
106
166
  export class BroadsideCancelledError extends Error {
107
167
  constructor(message = "Broad-Side submission cancelled. Nothing was submitted.") {
@@ -799,14 +859,23 @@ const SKIP_FILE_EXTENSIONS = new Set([
799
859
  ".model",
800
860
  ".bpe",
801
861
  ]);
862
+ /**
863
+ * Manifest files and the languages each one can mean. `package.json` covers
864
+ * both TypeScript and JavaScript; which of the two a repository is comes from
865
+ * counting its source files, not from the manifest.
866
+ */
802
867
  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"],
868
+ ["go.mod", ["go"]],
869
+ ["package.json", ["typescript", "javascript"]],
870
+ ["Cargo.toml", ["rust"]],
871
+ ["pyproject.toml", ["python"]],
872
+ ["setup.py", ["python"]],
873
+ ["requirements.txt", ["python"]],
809
874
  ];
875
+ /** The languages Broad-Side can scan; anything else is refused at submit. */
876
+ export const BROADSIDE_LANGUAGES = ["go", "python", "rust", "typescript", "javascript"];
877
+ /** Chars of the entry-point file and the manifest that ride in the architecture prompt (#249). */
878
+ const REPO_INFO_FILE_CAP = 20_000;
810
879
  const SOURCE_SPECS = {
811
880
  go: { glob: "**/*.go", exts: [".go"] },
812
881
  python: { glob: "**/*.py", exts: [".py"] },
@@ -814,21 +883,32 @@ const SOURCE_SPECS = {
814
883
  typescript: { glob: "**/*.ts", exts: [".ts", ".tsx"] },
815
884
  javascript: { glob: "**/*.js", exts: [".js", ".jsx"] },
816
885
  };
886
+ /**
887
+ * The files a run scans, and where they came from. Contents are always read
888
+ * from the working tree, so the list is the working tree's too: tracked files
889
+ * plus untracked ones git does not ignore, minus files deleted on disk. The
890
+ * list used to come from `git ls-tree HEAD`, so a run mixed the committed
891
+ * file list with uncommitted contents and never saw an untracked file (#248).
892
+ * A target that is not a git repository gets a bounded walk.
893
+ */
817
894
  async function listRepoFiles(targetDir) {
818
- // git ls-tree is the fast path; fall back to a bounded walk for non-git trees.
819
895
  try {
820
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "ls-tree", "-r", "--name-only", "HEAD"], {
896
+ const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
897
+ const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
821
898
  maxBuffer: 64 * 1024 * 1024,
899
+ timeout: GIT_TIMEOUT_MS,
822
900
  });
823
- return stdout.split("\n").filter(Boolean);
901
+ const gone = new Set(deleted.stdout.split("\0").filter(Boolean));
902
+ const files = listed.stdout.split("\0").filter((path) => path && !gone.has(path));
903
+ return { files, snapshot: "working-tree" };
824
904
  }
825
905
  catch {
826
- return walkFiles(targetDir, targetDir, 0, 30_000);
906
+ return { files: await walkFiles(targetDir, targetDir, 0, 30_000), snapshot: "walk" };
827
907
  }
828
908
  }
829
909
  async function gitHead(targetDir) {
830
910
  try {
831
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024 });
911
+ const { stdout } = await execFileAsync("git", ["-C", targetDir, "rev-parse", "HEAD"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
832
912
  return stdout.trim() || null;
833
913
  }
834
914
  catch {
@@ -837,7 +917,7 @@ async function gitHead(targetDir) {
837
917
  }
838
918
  async function gitDirty(targetDir) {
839
919
  try {
840
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024 });
920
+ const { stdout } = await execFileAsync("git", ["-C", targetDir, "status", "--porcelain"], { maxBuffer: 1024 * 1024, timeout: GIT_TIMEOUT_MS });
841
921
  return stdout.trim().length > 0;
842
922
  }
843
923
  catch {
@@ -853,7 +933,7 @@ async function changedFilesSince(targetDir, baseHead) {
853
933
  if (!baseHead)
854
934
  return null;
855
935
  try {
856
- const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024 });
936
+ const { stdout } = await execFileAsync("git", ["-C", targetDir, "diff", "--name-only", baseHead, "HEAD"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
857
937
  return new Set(stdout.split("\n").filter(Boolean));
858
938
  }
859
939
  catch {
@@ -872,7 +952,7 @@ async function walkFiles(rootDir, dir, depth, remaining) {
872
952
  return out;
873
953
  }
874
954
  for (const entry of entries) {
875
- if (entry.name.startsWith(".") && entry.name !== ".github")
955
+ if (entry.name.startsWith("."))
876
956
  continue;
877
957
  if (entry.isDirectory()) {
878
958
  if (SKIP_DIR_NAMES.has(entry.name))
@@ -892,25 +972,65 @@ async function walkFiles(rootDir, dir, depth, remaining) {
892
972
  }
893
973
  return out;
894
974
  }
895
- function detectLanguage(fileCounts, manifestPath) {
896
- if (manifestPath) {
897
- for (const [candidate, lang] of MANIFEST_CANDIDATES) {
898
- if (manifestPath === candidate)
899
- return lang;
975
+ function sourceFileCount(language, fileCounts) {
976
+ return (SOURCE_SPECS[language]?.exts ?? []).reduce((sum, ext) => sum + (fileCounts[ext] ?? 0), 0);
977
+ }
978
+ /**
979
+ * The language the lenses scan as. The manifests present name the candidates
980
+ * (all of them, not the first one found: a Python service with a
981
+ * `package.json` for its docs tooling is not a TypeScript repository), and
982
+ * among candidates the one with the most source files wins; without a
983
+ * manifest, the language with the most source files; without any source
984
+ * file, `unknown` — which submit refuses rather than scanning nothing and
985
+ * paying for it (#250). Ties keep manifest order.
986
+ */
987
+ function detectLanguage(fileCounts, manifestPaths) {
988
+ const candidates = [];
989
+ for (const [candidate, languages] of MANIFEST_CANDIDATES) {
990
+ if (!manifestPaths.includes(candidate))
991
+ continue;
992
+ for (const language of languages)
993
+ if (!candidates.includes(language))
994
+ candidates.push(language);
995
+ }
996
+ const pool = candidates.length > 0 ? candidates : [...BROADSIDE_LANGUAGES];
997
+ let best = null;
998
+ let bestCount = -1;
999
+ for (const language of pool) {
1000
+ const count = sourceFileCount(language, fileCounts);
1001
+ if (count > bestCount) {
1002
+ best = language;
1003
+ bestCount = count;
900
1004
  }
901
1005
  }
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";
1006
+ if (bestCount > 0)
1007
+ return best;
1008
+ // A manifest with no source files behind it still names the language;
1009
+ // submit reports the empty count. No manifest and no source: unknown.
1010
+ return candidates[0] ?? "unknown";
911
1011
  }
912
- export async function collectRepoInfo(targetDir) {
913
- const allFiles = await listRepoFiles(targetDir);
1012
+ /** Cut a file that rides whole in a prompt down to the cap, saying so (#249). */
1013
+ function capForPrompt(content, cap) {
1014
+ if (content.length <= cap)
1015
+ return content;
1016
+ return `${content.slice(0, cap)}\n… [truncated: ${cap.toLocaleString()} of ${content.length.toLocaleString()} chars shown]\n`;
1017
+ }
1018
+ export async function collectRepoInfo(targetDir, opts = {}) {
1019
+ const redact = opts.redact ?? true;
1020
+ const { files: allFiles, snapshot } = await listRepoFiles(targetDir);
1021
+ // Named credential stores are out of every lens (isSlurpable); listed here
1022
+ // so the submit report can say so.
1023
+ const secretFilesSkipped = allFiles.filter((path) => isSecretFile(path)).sort();
1024
+ let redactedValues = 0;
1025
+ // The entry point, manifest, and README ride in the architecture prompt
1026
+ // as text, so they get the same pass the slices do (#252).
1027
+ const clean = (text) => {
1028
+ if (!redact)
1029
+ return text;
1030
+ const redaction = redactSecrets(text);
1031
+ redactedValues += redaction.count;
1032
+ return redaction.text;
1033
+ };
914
1034
  const fileCounts = {};
915
1035
  for (const f of allFiles) {
916
1036
  const slash = f.lastIndexOf("/");
@@ -923,25 +1043,37 @@ export async function collectRepoInfo(targetDir) {
923
1043
  for (const [ext, n] of Object.entries(fileCounts).sort((a, b) => b[1] - a[1])) {
924
1044
  sortedCounts[ext] = n;
925
1045
  }
926
- let manifest = null;
1046
+ // Every manifest present counts toward language detection; the first one
1047
+ // found is the one the architecture prompt shows.
1048
+ const manifestPaths = [];
927
1049
  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;
1050
+ if (await pathExists(join(targetDir, candidate)))
1051
+ manifestPaths.push(candidate);
1052
+ }
1053
+ const language = detectLanguage(sortedCounts, manifestPaths);
1054
+ // Show the manifest that belongs to the detected language when there is
1055
+ // one, so a polyglot repo's prompt does not open with the other stack's file.
1056
+ const manifestPath = manifestPaths.find((path) => MANIFEST_CANDIDATES.find(([candidate]) => candidate === path)?.[1].includes(language))
1057
+ ?? manifestPaths[0]
1058
+ ?? null;
1059
+ let manifest = null;
1060
+ if (manifestPath) {
1061
+ try {
1062
+ manifest = { path: manifestPath, content: capForPrompt(clean(await readFile(join(targetDir, manifestPath), "utf8")), REPO_INFO_FILE_CAP) };
1063
+ }
1064
+ catch {
1065
+ manifest = null;
937
1066
  }
938
1067
  }
1068
+ // Read whole and unbounded before, and then estimated at a flat 6,000
1069
+ // chars: a large entry point shipped in full while the cap was checked
1070
+ // against a number that had nothing to do with it (#249).
939
1071
  let mainFile = "";
940
- for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts"]) {
1072
+ for (const candidate of ["main.go", "main.py", "src/main.rs", "src/index.ts", "index.ts", "src/index.js", "index.js"]) {
941
1073
  const p = join(targetDir, candidate);
942
1074
  if (await pathExists(p)) {
943
1075
  try {
944
- mainFile = await readFile(p, "utf8");
1076
+ mainFile = capForPrompt(clean(await readFile(p, "utf8")), REPO_INFO_FILE_CAP);
945
1077
  }
946
1078
  catch {
947
1079
  mainFile = "";
@@ -953,16 +1085,18 @@ export async function collectRepoInfo(targetDir) {
953
1085
  const readmePath = join(targetDir, "README.md");
954
1086
  if (await pathExists(readmePath)) {
955
1087
  try {
956
- readmeFirst = (await readFile(readmePath, "utf8")).slice(0, 4000);
1088
+ readmeFirst = clean((await readFile(readmePath, "utf8")).slice(0, 4000));
957
1089
  }
958
1090
  catch {
959
1091
  readmeFirst = "";
960
1092
  }
961
1093
  }
962
1094
  const fileTree = buildFileTree(allFiles);
963
- const language = detectLanguage(sortedCounts, manifest?.path ?? null);
964
- const sourceSpec = SOURCE_SPECS[language] ?? SOURCE_SPECS.go;
1095
+ // An unknown language used to fall through to Go's globs, so the code
1096
+ // lenses matched nothing and the run paid for empty batches (#250).
1097
+ const sourceSpec = SOURCE_SPECS[language] ?? { glob: "", exts: [] };
965
1098
  const name = targetDir.split(/[\\/]/).filter(Boolean).pop() ?? "repo";
1099
+ const sourceFiles = allFiles.filter((path) => isSlurpable(path) && sourceSpec.exts.some((ext) => path.toLowerCase().endsWith(ext))).length;
966
1100
  return {
967
1101
  name,
968
1102
  path: targetDir,
@@ -974,6 +1108,10 @@ export async function collectRepoInfo(targetDir) {
974
1108
  fileCounts: sortedCounts,
975
1109
  sourceGlob: sourceSpec.glob,
976
1110
  sourceExts: sourceSpec.exts,
1111
+ sourceFileCount: sourceFiles,
1112
+ snapshot,
1113
+ secretFilesSkipped,
1114
+ redactedValues,
977
1115
  };
978
1116
  }
979
1117
  function buildFileTree(allFiles, maxDepth = 3, maxLines = 200) {
@@ -1034,6 +1172,9 @@ function matchesAnyGlob(path, globs) {
1034
1172
  return false;
1035
1173
  }
1036
1174
  function isSlurpable(relPath) {
1175
+ // A credential store is never a lens input, whatever its globs say (#252).
1176
+ if (isSecretFile(relPath))
1177
+ return false;
1037
1178
  const segments = relPath.split("/");
1038
1179
  for (const seg of segments) {
1039
1180
  if (SKIP_DIR_NAMES.has(seg))
@@ -1070,7 +1211,7 @@ function resolveSliceMode(lens, files, totalChars) {
1070
1211
  return totalChars > lens.maxChars ? "directory" : "none";
1071
1212
  }
1072
1213
  function collectLensFiles(allFiles, lens, info) {
1073
- const globs = lens.globsFor(info);
1214
+ const globs = lens.globsFor(info).filter(Boolean);
1074
1215
  if (globs.length === 0)
1075
1216
  return [];
1076
1217
  const out = [];
@@ -1085,13 +1226,15 @@ function collectLensFiles(allFiles, lens, info) {
1085
1226
  }
1086
1227
  return out;
1087
1228
  }
1088
- async function slurpFileList(targetDir, files, maxChars) {
1229
+ async function slurpFileList(targetDir, files, maxChars, redact = true) {
1089
1230
  const slices = [];
1090
1231
  let currentModule = "";
1091
1232
  let parts = [];
1092
1233
  let running = 0;
1093
1234
  let fileCount = 0;
1094
1235
  let filePaths = [];
1236
+ let redactedValues = 0;
1237
+ let redactedFiles = [];
1095
1238
  const flush = () => {
1096
1239
  if (parts.length === 0)
1097
1240
  return;
@@ -1101,20 +1244,38 @@ async function slurpFileList(targetDir, files, maxChars) {
1101
1244
  fileCount,
1102
1245
  chars: running,
1103
1246
  files: filePaths,
1247
+ redactedValues,
1248
+ redactedFiles,
1104
1249
  });
1105
1250
  parts = [];
1106
1251
  running = 0;
1107
1252
  fileCount = 0;
1108
1253
  filePaths = [];
1254
+ redactedValues = 0;
1255
+ redactedFiles = [];
1109
1256
  };
1110
1257
  for (const file of files) {
1111
1258
  let content = "";
1112
1259
  try {
1113
1260
  content = await readFile(join(targetDir, file.relPath), "utf8");
1114
1261
  }
1115
- catch {
1262
+ catch (error) {
1263
+ // The listing is the working tree's, so this is a race with a
1264
+ // concurrent delete rather than a listed-but-deleted file; skip it.
1265
+ if (error.code === "ENOENT")
1266
+ continue;
1116
1267
  content = "[BINARY or UNREADABLE]";
1117
1268
  }
1269
+ if (redact) {
1270
+ // Before the slice is built, so the count and the chars the estimate
1271
+ // sees are of what is actually sent (#252).
1272
+ const redaction = redactSecrets(content);
1273
+ if (redaction.count > 0) {
1274
+ content = redaction.text;
1275
+ redactedValues += redaction.count;
1276
+ redactedFiles.push(file.relPath);
1277
+ }
1278
+ }
1118
1279
  const block = `=== ${file.relPath} ===\n${content}\n`;
1119
1280
  if (file.moduleName !== currentModule && parts.length > 0) {
1120
1281
  flush();
@@ -1134,12 +1295,13 @@ async function slurpFileList(targetDir, files, maxChars) {
1134
1295
  flush();
1135
1296
  return slices;
1136
1297
  }
1137
- export async function gatherSlices(targetDir, lens, info) {
1298
+ export async function gatherSlices(targetDir, lens, info, opts = {}) {
1299
+ const redact = opts.redact ?? true;
1138
1300
  if (lens.sliceBy === "none" && lens.globsFor(info).length === 0) {
1139
1301
  // Repo-info lens (architecture): the prompt is built from info alone.
1140
1302
  return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
1141
1303
  }
1142
- const allFiles = await listRepoFiles(targetDir);
1304
+ const { files: allFiles } = await listRepoFiles(targetDir);
1143
1305
  const files = collectLensFiles(allFiles, lens, info);
1144
1306
  const totalChars = await sumFileSizes(targetDir, files);
1145
1307
  const mode = resolveSliceMode(lens, files, totalChars);
@@ -1147,9 +1309,9 @@ export async function gatherSlices(targetDir, lens, info) {
1147
1309
  // Whole-repo slice: one module named after the repo, so a small
1148
1310
  // repo produces a single request instead of one per directory.
1149
1311
  const single = files.map((f) => ({ ...f, moduleName: info.name }));
1150
- return slurpFileList(targetDir, single, lens.maxChars);
1312
+ return slurpFileList(targetDir, single, lens.maxChars, redact);
1151
1313
  }
1152
- return slurpFileList(targetDir, files, lens.maxChars);
1314
+ return slurpFileList(targetDir, files, lens.maxChars, redact);
1153
1315
  }
1154
1316
  async function sumFileSizes(targetDir, files) {
1155
1317
  let total = 0;
@@ -1199,7 +1361,12 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
1199
1361
  * estimate covers slice content only, which is what the old signature did.
1200
1362
  */
1201
1363
  export function estimateCost(lens, slices, pricing, maxTokensOverride, info) {
1202
- const sliceChars = slices.reduce((sum, s) => sum + (lens.maxChars === 0 ? 6000 : s.chars), 0);
1364
+ // With repo info, size each request from the user prompt that would be
1365
+ // sent, which is what the architecture lens is made of: it used to be
1366
+ // estimated at a flat 6,000 chars while the entry point, manifest, README
1367
+ // excerpt and file tree it carries ran to whatever they ran to (#249).
1368
+ // Without info, the slice content alone is what the old signature covered.
1369
+ const sliceChars = slices.reduce((sum, s) => sum + (info ? lens.userPrompt(info, s.content, s.moduleName).length : lens.maxChars === 0 ? 6000 : s.chars), 0);
1203
1370
  // The system prompt and the response schema ride on every request, so they
1204
1371
  // are paid once per slice rather than once per lens.
1205
1372
  const perRequestOverhead = info
@@ -1247,15 +1414,30 @@ export async function loadBroadsideState(broadsideDir) {
1247
1414
  const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
1248
1415
  if (!(await pathExists(statePath)))
1249
1416
  return defaultBroadsideState();
1417
+ const text = await readFile(statePath, "utf8");
1418
+ let raw;
1250
1419
  try {
1251
- const raw = JSON.parse(await readFile(statePath, "utf8"));
1252
- if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs))
1253
- return defaultBroadsideState();
1254
- return raw;
1420
+ raw = JSON.parse(text);
1255
1421
  }
1256
- catch {
1257
- return defaultBroadsideState();
1422
+ catch (error) {
1423
+ throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
1258
1424
  }
1425
+ if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs)) {
1426
+ throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), "is not a state file (expected an object with a runs array)");
1427
+ }
1428
+ return raw;
1429
+ }
1430
+ /**
1431
+ * Copy an unreadable state file to `state.json.corrupt-<hash>` beside it,
1432
+ * named by content so repeated loads do not multiply copies. Returns the
1433
+ * copy's path (the existing one, when the same content was preserved before).
1434
+ */
1435
+ async function preserveCorruptState(statePath, text) {
1436
+ const digest = createHash("sha1").update(text).digest("hex").slice(0, 8);
1437
+ const backupPath = `${statePath}.corrupt-${digest}`;
1438
+ if (!(await pathExists(backupPath)))
1439
+ await writeFile(backupPath, text, "utf8");
1440
+ return backupPath;
1259
1441
  }
1260
1442
  /**
1261
1443
  * Overwrite `state.json` wholesale with `state`.
@@ -1348,13 +1530,26 @@ export async function loadBroadsideConfig(broadsideDir) {
1348
1530
  const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
1349
1531
  let raw = {};
1350
1532
  if (await pathExists(configPath)) {
1533
+ let parsed;
1351
1534
  try {
1352
- raw = (await loadYamlFile(configPath)) ?? {};
1535
+ parsed = await loadYamlFile(configPath);
1353
1536
  }
1354
- catch {
1355
- raw = {};
1537
+ catch (error) {
1538
+ throw new BroadsideConfigError(configPath, `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
1539
+ }
1540
+ if (parsed !== null && parsed !== undefined) {
1541
+ if (typeof parsed !== "object" || Array.isArray(parsed))
1542
+ throw new BroadsideConfigError(configPath, "is not a YAML mapping");
1543
+ raw = parsed;
1356
1544
  }
1357
1545
  }
1546
+ return buildBroadsideConfig(raw);
1547
+ }
1548
+ /** The shipped defaults: what an absent config.yaml means. */
1549
+ export function defaultBroadsideConfig() {
1550
+ return buildBroadsideConfig({});
1551
+ }
1552
+ function buildBroadsideConfig(raw) {
1358
1553
  const lenses = Array.isArray(raw.default_lenses)
1359
1554
  ? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
1360
1555
  : [];
@@ -1379,7 +1574,9 @@ export async function loadBroadsideConfig(broadsideDir) {
1379
1574
  model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
1380
1575
  apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
1381
1576
  defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
1382
- maxCost: typeof raw.max_cost === "number" && raw.max_cost > 0 ? raw.max_cost : 0,
1577
+ // Absent: the shipped default. An explicit 0 is "no limit", spelled out
1578
+ // on purpose; a negative or non-numeric value is not a limit at all.
1579
+ maxCost: typeof raw.max_cost === "number" && raw.max_cost >= 0 ? raw.max_cost : BROADSIDE_DEFAULT_MAX_COST,
1383
1580
  pricing: inputOverride !== undefined && outputOverride !== undefined
1384
1581
  ? { inputPerM: inputOverride, outputPerM: outputOverride }
1385
1582
  : null,
@@ -1393,15 +1590,21 @@ export async function loadBroadsideConfig(broadsideDir) {
1393
1590
  includeSynthesis: flag("include_synthesis", true),
1394
1591
  includeTriage: flag("include_triage", true),
1395
1592
  waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
1593
+ redactSecrets: flag("redact_secrets", true),
1396
1594
  };
1397
1595
  }
1596
+ // ---------- model catalog, pricing, benchmarks ----------
1597
+ /** The catalog cache schema this build writes; a file from another is not read. */
1598
+ export const BROADSIDE_CATALOG_CACHE_SCHEMA = 3;
1398
1599
  async function readCatalogCache(broadsideDir) {
1399
1600
  const cachePath = join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE);
1400
1601
  if (!(await pathExists(cachePath)))
1401
1602
  return null;
1402
1603
  try {
1403
1604
  const parsed = JSON.parse(await readFile(cachePath, "utf8"));
1404
- if (!parsed || typeof parsed !== "object" || typeof parsed.models !== "object")
1605
+ if (!parsed || typeof parsed !== "object" || !parsed.models || typeof parsed.models !== "object")
1606
+ return null;
1607
+ if (parsed.schema_version !== BROADSIDE_CATALOG_CACHE_SCHEMA && parsed.schema_version !== 2)
1405
1608
  return null;
1406
1609
  return parsed;
1407
1610
  }
@@ -1409,6 +1612,11 @@ async function readCatalogCache(broadsideDir) {
1409
1612
  return null;
1410
1613
  }
1411
1614
  }
1615
+ /** When a cached entry was fetched: its own stamp, or the file's for a schema-2 cache. */
1616
+ function catalogEntryFetchedAt(cache, model) {
1617
+ const stamp = cache.models[model]?.fetched_at ?? cache.fetched_at;
1618
+ return new Date(stamp).getTime();
1619
+ }
1412
1620
  async function writeCatalogCache(broadsideDir, cache) {
1413
1621
  await mkdir(broadsideDir, { recursive: true });
1414
1622
  await writeFile(join(broadsideDir, BROADSIDE_CATALOG_CACHE_FILE), `${JSON.stringify(cache, null, "\t")}\n`, "utf8");
@@ -1488,31 +1696,51 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
1488
1696
  // constants below are what we fall back to when the network is unavailable.
1489
1697
  const cache = await readCatalogCache(broadsideDir);
1490
1698
  const cached = cache?.models[model];
1491
- if (cached && Date.now() - new Date(cache.fetched_at).getTime() < BROADSIDE_CATALOG_CACHE_TTL_MS) {
1699
+ if (cache && cached && Date.now() - catalogEntryFetchedAt(cache, model) < BROADSIDE_CATALOG_CACHE_TTL_MS) {
1492
1700
  return { model, source: "cache", entry: cached };
1493
1701
  }
1702
+ // What went wrong when the live lookup produced nothing, for the error
1703
+ // below: a 401 and a dead network used to read the same — "could not
1704
+ // resolve per-token pricing" — or, for the default model, nothing at all.
1494
1705
  let live = null;
1706
+ let catalogFailure = null;
1495
1707
  try {
1496
1708
  const resp = await fetcher(BROADSIDE_MODELS_URL, {
1497
1709
  method: "GET",
1498
1710
  headers: { Authorization: `Bearer ${apiKey}` },
1499
1711
  signal: AbortSignal.timeout(30_000),
1500
1712
  });
1501
- const data = (await resp.json());
1502
- const hit = (data.data ?? []).find((m) => String(m.id) === model);
1503
- if (hit)
1504
- live = parseCatalogEntry(hit);
1713
+ if (resp.status === 401 || resp.status === 403) {
1714
+ throw new BroadsideAuthError(resp.status, await responseDetail(resp));
1715
+ }
1716
+ if (resp.ok === false) {
1717
+ catalogFailure = `the model catalog request failed (HTTP ${resp.status}${await responseDetail(resp).then((d) => (d ? `: ${d}` : ""))})`;
1718
+ }
1719
+ else {
1720
+ const data = (await resp.json());
1721
+ const hit = (data.data ?? []).find((m) => String(m.id) === model);
1722
+ if (hit)
1723
+ live = parseCatalogEntry(hit);
1724
+ else
1725
+ catalogFailure = `the model catalog has no entry for "${model}"`;
1726
+ }
1505
1727
  }
1506
- catch {
1728
+ catch (error) {
1729
+ if (error instanceof BroadsideAuthError)
1730
+ throw error;
1507
1731
  live = null;
1732
+ catalogFailure = `the model catalog could not be fetched (${error instanceof Error ? error.message : String(error)})`;
1508
1733
  }
1509
1734
  if (live) {
1735
+ const now = new Date().toISOString();
1510
1736
  const updated = {
1511
- schema_version: 2,
1512
- fetched_at: new Date().toISOString(),
1513
- models: { ...(cache?.models ?? {}) },
1737
+ schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA,
1738
+ fetched_at: now,
1739
+ // Other entries keep their own stamps (a schema-2 file's entries
1740
+ // inherit the file's, once, on this upgrade); only this model is fresh.
1741
+ models: Object.fromEntries(Object.entries(cache?.models ?? {}).map(([id, entry]) => [id, { ...entry, fetched_at: entry.fetched_at ?? cache.fetched_at }])),
1514
1742
  };
1515
- updated.models[model] = live;
1743
+ updated.models[model] = { ...live, fetched_at: now };
1516
1744
  await writeCatalogCache(broadsideDir, updated);
1517
1745
  return { model, source: "live", entry: live };
1518
1746
  }
@@ -1521,10 +1749,37 @@ export async function resolveCatalogEntry(broadsideDir, config, model, apiKey, f
1521
1749
  const builtIn = builtInCatalogEntry(model);
1522
1750
  if (builtIn)
1523
1751
  return { model, source: "built-in", entry: builtIn };
1524
- throw new Error(`Could not resolve per-token pricing for batch model "${model}". ` +
1752
+ throw new Error(`Could not resolve per-token pricing for batch model "${model}": ${catalogFailure ?? "no catalog entry"}. ` +
1525
1753
  "Set pricing.input_per_m and pricing.output_per_m in .codecarto/broadside/config.yaml " +
1526
1754
  "(USD per million tokens), or check the model id against https://openrouter.ai/models?variant=batch.");
1527
1755
  }
1756
+ /** A short, safe excerpt of an error response body for a message. */
1757
+ async function responseDetail(resp) {
1758
+ try {
1759
+ if (typeof resp.text === "function") {
1760
+ const text = (await resp.text()).trim();
1761
+ try {
1762
+ const parsed = JSON.parse(text);
1763
+ const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
1764
+ if (typeof message === "string" && message)
1765
+ return message.slice(0, 200);
1766
+ }
1767
+ catch {
1768
+ // not JSON; fall through to the raw excerpt
1769
+ }
1770
+ return text.replace(/\s+/g, " ").slice(0, 200);
1771
+ }
1772
+ if (typeof resp.json === "function") {
1773
+ const parsed = (await resp.json());
1774
+ const message = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.message;
1775
+ return typeof message === "string" ? message.slice(0, 200) : "";
1776
+ }
1777
+ }
1778
+ catch {
1779
+ // an unreadable body adds nothing to the message
1780
+ }
1781
+ return "";
1782
+ }
1528
1783
  export async function resolveModelPricing(broadsideDir, config, model, apiKey, fetcher = fetch) {
1529
1784
  const { source, entry } = await resolveCatalogEntry(broadsideDir, config, model, apiKey, fetcher);
1530
1785
  if (!entry)
@@ -1582,9 +1837,10 @@ export async function listBatchModels(broadsideDir, config, apiKey, opts = {}) {
1582
1837
  }
1583
1838
  entries.sort((a, b) => a.inputPerM + a.outputPerM - (b.inputPerM + b.outputPerM));
1584
1839
  // Persist the catalog so the next submit's pricing resolution hits cache.
1585
- const cache = { schema_version: 2, fetched_at: new Date().toISOString(), models: {} };
1840
+ const fetchedAt = new Date().toISOString();
1841
+ const cache = { schema_version: BROADSIDE_CATALOG_CACHE_SCHEMA, fetched_at: fetchedAt, models: {} };
1586
1842
  for (const entry of entries)
1587
- cache.models[entry.id] = entry;
1843
+ cache.models[entry.id] = { ...entry, fetched_at: fetchedAt };
1588
1844
  await writeCatalogCache(broadsideDir, cache);
1589
1845
  const benchmarks = opts.includeBenchmarks ? await fetchCodingBenchmarks(apiKey, fetcher) : null;
1590
1846
  return { entries, source: "live", benchmarks, defaultModel: config.model };
@@ -1618,7 +1874,17 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
1618
1874
  headers: { Authorization: `Bearer ${apiKey}` },
1619
1875
  signal: AbortSignal.timeout(30_000),
1620
1876
  });
1621
- const data = (await resp.json());
1877
+ let data;
1878
+ try {
1879
+ data = (await resp.json());
1880
+ }
1881
+ catch (error) {
1882
+ // A gateway error page is not JSON. It used to throw out of here and
1883
+ // be retried as if the network were down; keep the status instead.
1884
+ data = { error: `non-JSON response (${error instanceof Error ? error.message : String(error)})` };
1885
+ }
1886
+ if (!data || typeof data !== "object")
1887
+ data = { error: "empty response" };
1622
1888
  // Surface the HTTP status so the poller can bail fast on auth expiry
1623
1889
  // instead of retrying a dead key for the whole budget.
1624
1890
  data.http_status = resp.status;
@@ -1632,18 +1898,38 @@ export async function fetchBatch(batchId, apiKey, fetcher = fetch) {
1632
1898
  * charged, so callers must come back for it rather than retire it.
1633
1899
  */
1634
1900
  export const BROADSIDE_DEAD_BATCH_STATUSES = ["failed", "expired", "cancelled", "auth-failed"];
1901
+ /**
1902
+ * Batch entry statuses collect never polls again: the dead ones above, plus
1903
+ * `completed`, plus the two a submit assigns without a batch (`skipped`: no
1904
+ * matching files; `rejected`: the provider refused it). The 0.19.1 changelog
1905
+ * called the dead set "a named constant rather than two hand-maintained
1906
+ * lists"; this set was still three literal copies (self-audit sem 5.8).
1907
+ */
1908
+ export const BROADSIDE_TERMINAL_ENTRY_STATUSES = ["completed", ...BROADSIDE_DEAD_BATCH_STATUSES, "skipped", "rejected"];
1635
1909
  export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
1636
1910
  const deadline = Date.now() + (opts.deadlineMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
1637
1911
  const intervalMs = opts.pollIntervalMs ?? BROADSIDE_POLL_INTERVAL_MS;
1638
1912
  const fetcher = opts.fetcher ?? fetch;
1913
+ // A poll that runs out of budget without one good response is not a slow
1914
+ // batch. The last thing that went wrong rides on the timeout so the report
1915
+ // can tell a dead network or a failing gateway from a batch still running.
1916
+ let lastError = null;
1917
+ let sawBatch = false;
1918
+ const timedOut = () => ({
1919
+ id: batchId,
1920
+ status: "timeout",
1921
+ ...(lastError && !sawBatch && { error: `no successful poll response; last error: ${lastError}` }),
1922
+ ...(lastError && sawBatch && { last_error: lastError }),
1923
+ });
1639
1924
  for (;;) {
1640
1925
  let batch;
1641
1926
  try {
1642
1927
  batch = await fetchBatch(batchId, apiKey, fetcher);
1643
1928
  }
1644
- catch {
1929
+ catch (error) {
1930
+ lastError = `fetch failed (${error instanceof Error ? error.message : String(error)})`;
1645
1931
  if (Date.now() >= deadline)
1646
- return { id: batchId, status: "timeout" };
1932
+ return timedOut();
1647
1933
  await sleep(intervalMs);
1648
1934
  continue;
1649
1935
  }
@@ -1651,13 +1937,23 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
1651
1937
  if (httpStatus === 401 || httpStatus === 403) {
1652
1938
  return { id: batchId, status: "auth-failed", error: batch.error ?? batch };
1653
1939
  }
1940
+ if (httpStatus >= 400) {
1941
+ // A gateway or server error: retry within the budget, remembered.
1942
+ const detail = typeof batch.error === "string" ? batch.error : JSON.stringify(batch.error ?? "");
1943
+ lastError = `HTTP ${httpStatus}${detail ? ` (${detail.slice(0, 200)})` : ""}`;
1944
+ if (Date.now() >= deadline)
1945
+ return timedOut();
1946
+ await sleep(intervalMs);
1947
+ continue;
1948
+ }
1949
+ sawBatch = true;
1654
1950
  const status = String(batch.status ?? "unknown");
1655
1951
  const counts = (batch.request_counts ?? {});
1656
1952
  opts.onStatus?.(status, counts);
1657
1953
  if (status === "completed" || BROADSIDE_DEAD_BATCH_STATUSES.includes(status))
1658
1954
  return batch;
1659
1955
  if (Date.now() >= deadline)
1660
- return { id: batchId, status: "timeout" };
1956
+ return timedOut();
1661
1957
  await sleep(intervalMs);
1662
1958
  }
1663
1959
  }
@@ -1684,7 +1980,6 @@ export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
1684
1980
  }
1685
1981
  // ---------- run orchestration ----------
1686
1982
  export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1687
- const info = await collectRepoInfo(cwd);
1688
1983
  const lensIds = opts.lenses ?? BROADSIDE_LENS_IDS;
1689
1984
  const broadsideDir = broadsideDirFor(cwd);
1690
1985
  const model = opts.model ?? BROADSIDE_MODEL;
@@ -1693,6 +1988,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1693
1988
  // structured-output support that not all batch models offer. Lenses may run
1694
1989
  // on different models (config `lens_models`), so each one is pre-flighted.
1695
1990
  const config = await loadBroadsideConfig(broadsideDir);
1991
+ const redact = config.redactSecrets;
1992
+ const info = await collectRepoInfo(cwd, { redact });
1993
+ // Before pricing, before the network, before any state write: a run on a
1994
+ // language the lenses cannot scan used to submit empty batches and pay for
1995
+ // them (#250).
1996
+ if (info.language === "unknown") {
1997
+ throw new Error(`Broad-Side could not tell what language this repository is: no ${MANIFEST_CANDIDATES.map(([candidate]) => candidate).join(", ")} ` +
1998
+ `and no source files in a language the lenses can scan (${BROADSIDE_LANGUAGES.join(", ")}). Nothing was submitted.`);
1999
+ }
2000
+ if (info.sourceFileCount === 0) {
2001
+ throw new Error(`Broad-Side found no ${info.language} source files to scan (detected from ${info.manifest?.path ?? "the file counts"}; ` +
2002
+ `the lenses look for ${info.sourceExts.join(", ")}). Nothing was submitted.`);
2003
+ }
1696
2004
  const modelForLens = (lensId) => config.lensModels[lensId] ?? model;
1697
2005
  const resolved = new Map();
1698
2006
  for (const candidate of new Set([model, ...lensIds.map(modelForLens)])) {
@@ -1758,9 +2066,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1758
2066
  let estimatedOutputTokens = 0;
1759
2067
  let estimatedTotalCost = 0;
1760
2068
  const perLensEstimate = [];
2069
+ // What the redaction pass did across every lens's slices, for the run
2070
+ // record and the report: a value in a file shared by two lenses counts
2071
+ // once per lens it was sent in, files once each.
2072
+ let redactedValues = info.redactedValues;
2073
+ const redactedFiles = new Set();
1761
2074
  for (const lensId of lensIds) {
1762
2075
  const lens = getLens(lensId);
1763
- let slices = await gatherSlices(cwd, lens, info);
2076
+ let slices = await gatherSlices(cwd, lens, info, { redact });
2077
+ for (const slice of slices) {
2078
+ redactedValues += slice.redactedValues ?? 0;
2079
+ for (const file of slice.redactedFiles ?? [])
2080
+ redactedFiles.add(file);
2081
+ }
1764
2082
  if (changed) {
1765
2083
  // Repo-info slices (empty files, e.g. architecture) always run;
1766
2084
  // file-backed slices run only when one of their files changed.
@@ -1819,7 +2137,9 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1819
2137
  `$${limit.toFixed(2)}. Nothing was submitted.\nBreakdown:\n${breakdown}\n` +
1820
2138
  `Pass force: true to submit anyway, or raise max_cost in .codecarto/broadside/config.yaml.`);
1821
2139
  }
1822
- const state = await loadBroadsideState(broadsideDir);
2140
+ // Read before anything is posted: a state.json that cannot be read refuses
2141
+ // the run here (#233), while persistBroadsideRun below merges by run id.
2142
+ await loadBroadsideState(broadsideDir);
1823
2143
  const runId = new Date().toISOString().replace(/[:.]/g, "-");
1824
2144
  const run = {
1825
2145
  id: runId,
@@ -1837,8 +2157,15 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1837
2157
  sourceHead,
1838
2158
  sourceDirty,
1839
2159
  baseHead,
2160
+ snapshot: info.snapshot,
2161
+ language: info.language,
2162
+ redaction: {
2163
+ enabled: redact,
2164
+ values: redactedValues,
2165
+ files: redactedFiles.size,
2166
+ skippedFiles: info.secretFilesSkipped.length,
2167
+ },
1840
2168
  };
1841
- state.runs.push(run);
1842
2169
  await persistBroadsideRun(broadsideDir, run);
1843
2170
  const requestsByCustomId = {};
1844
2171
  const submissions = [];
@@ -1915,6 +2242,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
1915
2242
  expirationDate: defaultEntry.expirationDate ?? null,
1916
2243
  },
1917
2244
  incremental: incrementalOutcome,
2245
+ repo: {
2246
+ language: info.language,
2247
+ sourceFiles: info.sourceFileCount,
2248
+ snapshot: info.snapshot,
2249
+ sourceHead,
2250
+ sourceDirty,
2251
+ },
2252
+ redaction: {
2253
+ enabled: redact,
2254
+ values: redactedValues,
2255
+ files: redactedFiles.size,
2256
+ skippedFiles: info.secretFilesSkipped,
2257
+ },
1918
2258
  };
1919
2259
  }
1920
2260
  function extractContent(result) {
@@ -2120,8 +2460,13 @@ function parseTriageItems(content) {
2120
2460
  export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2121
2461
  const broadsideDir = broadsideDirFor(cwd);
2122
2462
  const state = await loadBroadsideState(broadsideDir);
2123
- const run = state.runs[state.runs.length - 1];
2463
+ const run = opts.runId ? state.runs.find((candidate) => candidate.id === opts.runId) : state.runs[state.runs.length - 1];
2124
2464
  if (!run) {
2465
+ if (opts.runId) {
2466
+ const known = state.runs.map((candidate) => candidate.id);
2467
+ throw new Error(`No Broad-Side run with id ${opts.runId}. ` +
2468
+ (known.length > 0 ? `Recorded runs: ${known.join(", ")}.` : "No runs are recorded; call codecarto_broadside with action 'submit' first."));
2469
+ }
2125
2470
  throw new Error("No Broad-Side run recorded. Call codecarto_broadside with action 'submit' first.");
2126
2471
  }
2127
2472
  const runDir = join(broadsideDir, run.outputDir);
@@ -2142,7 +2487,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2142
2487
  lensOutcomes[lensId] = { status: entry?.status ?? "failed", resultCount: 0 };
2143
2488
  continue;
2144
2489
  }
2145
- if (["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status)) {
2490
+ if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status)) {
2146
2491
  totalCost += entry.cost ?? 0;
2147
2492
  resultCount += entry.resultCount ?? 0;
2148
2493
  lensOutcomes[lensId] = { status: entry.status, cost: entry.cost, resultCount: entry.resultCount };
@@ -2187,7 +2532,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2187
2532
  // indistinguishable in the output from one that was never requested.
2188
2533
  if (batch.error)
2189
2534
  entry.error = batch.error;
2190
- lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount };
2535
+ const error = describeBatchError(batch.error);
2536
+ lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
2191
2537
  }
2192
2538
  await persistBroadsideRun(broadsideDir, run);
2193
2539
  }
@@ -2281,7 +2627,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2281
2627
  if ((wantSynthesis || wantTriage) && allLensResults.length > 0) {
2282
2628
  const allTerminal = run.lenses.every((lensId) => {
2283
2629
  const entry = run.batches[lensId];
2284
- return entry && ["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status);
2630
+ return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
2285
2631
  });
2286
2632
  if (allTerminal && (postPassUnfinished(run.synthesis) || postPassUnfinished(run.triage))) {
2287
2633
  const findingsText = allLensResults
@@ -2393,7 +2739,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
2393
2739
  }
2394
2740
  const terminal = run.lenses.every((lensId) => {
2395
2741
  const entry = run.batches[lensId];
2396
- return entry && ["completed", "failed", "expired", "cancelled", "auth-failed", "skipped", "rejected"].includes(entry.status);
2742
+ return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
2397
2743
  });
2398
2744
  run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
2399
2745
  run.totalCost = totalCost;
@@ -2534,6 +2880,18 @@ export function estimateSubmitText(result, lenses) {
2534
2880
  const override = entry.model ? ` on ${entry.model}` : "";
2535
2881
  lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}`);
2536
2882
  }
2883
+ if (result.repo) {
2884
+ const head = result.repo.sourceHead ? ` at ${result.repo.sourceHead.slice(0, 8)}${result.repo.sourceDirty ? " (dirty)" : ""}` : "";
2885
+ const source = result.repo.snapshot === "working-tree" ? `working tree${head}` : "directory walk (not a git repository)";
2886
+ lines.push(`Scanned as ${result.repo.language}: ${result.repo.sourceFiles} source file(s) from the ${source}.`);
2887
+ }
2888
+ if (result.redaction) {
2889
+ const line = result.redaction.enabled
2890
+ ? describeRedactions(result.redaction.values, result.redaction.files, result.redaction.skippedFiles)
2891
+ : "Before upload: secret redaction is OFF (redact_secrets: false in config.yaml); files were sent as they are.";
2892
+ if (line)
2893
+ lines.push(line);
2894
+ }
2537
2895
  const incremental = result.incremental;
2538
2896
  if (incremental?.requested) {
2539
2897
  lines.push(incremental.applied
@@ -2586,6 +2944,25 @@ export function modelsText(entries, opts) {
2586
2944
  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.");
2587
2945
  return lines.join("\n");
2588
2946
  }
2947
+ /** One line of a batch's error field, whatever shape the provider gave it. */
2948
+ function describeBatchError(error) {
2949
+ if (error === undefined || error === null || error === "")
2950
+ return null;
2951
+ if (typeof error === "string")
2952
+ return error.slice(0, 300);
2953
+ if (typeof error === "object") {
2954
+ const message = error.message;
2955
+ if (typeof message === "string" && message)
2956
+ return message.slice(0, 300);
2957
+ try {
2958
+ return JSON.stringify(error).slice(0, 300);
2959
+ }
2960
+ catch {
2961
+ return String(error);
2962
+ }
2963
+ }
2964
+ return String(error);
2965
+ }
2589
2966
  export function collectResultText(result) {
2590
2967
  const lines = [
2591
2968
  `Broad-Side run ${result.runId}: ${result.status}`,
@@ -2599,7 +2976,10 @@ export function collectResultText(result) {
2599
2976
  lines.push(` ${lensId}: ${outcome.status}` +
2600
2977
  (outcome.cost !== undefined ? `, $${outcome.cost.toFixed(6)}` : "") +
2601
2978
  (outcome.resultCount !== undefined ? `, ${outcome.resultCount} result(s)` : "") +
2602
- truncation);
2979
+ truncation +
2980
+ // The reason a lens did not complete, when the poll recorded one:
2981
+ // an auth failure or a dead network used to read as a slow batch.
2982
+ (outcome.error ? ` — ${outcome.error}` : ""));
2603
2983
  }
2604
2984
  if (result.retriedCount > 0) {
2605
2985
  lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
@@ -2639,6 +3019,12 @@ export function statusText(state) {
2639
3019
  const lines = [];
2640
3020
  for (const run of [...state.runs].reverse().slice(0, 3)) {
2641
3021
  lines.push(`Run ${run.id} — ${run.status}`);
3022
+ // Recorded since #248; a run from an older version has neither field.
3023
+ if (run.language || run.snapshot) {
3024
+ const head = run.sourceHead ? ` at ${run.sourceHead.slice(0, 8)}${run.sourceDirty ? " (dirty)" : ""}` : "";
3025
+ const source = run.snapshot === "walk" ? "directory walk" : run.snapshot ? `working tree${head}` : "unknown source";
3026
+ lines.push(` scanned as ${run.language ?? "unknown"} from the ${source}`);
3027
+ }
2642
3028
  for (const lensId of BROADSIDE_LENS_IDS) {
2643
3029
  const entry = run.batches[lensId];
2644
3030
  if (!entry)