codecartographer-pi 0.22.3 → 0.24.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.
@@ -698,6 +698,7 @@ const LENSES = {
698
698
  "**/*handler*",
699
699
  "**/*endpoint*",
700
700
  ],
701
+ fallbackGlobsFor: (info) => [info.sourceGlob],
701
702
  systemPrompt: () => "You are a senior API auditor. Given source files from an HTTP server, " +
702
703
  "extract every HTTP endpoint (method, path, handler function, auth requirement) " +
703
704
  "and every key request/response data type. Return a JSON object following the " +
@@ -718,6 +719,7 @@ const LENSES = {
718
719
  globsFor: (info) => info.language === "go"
719
720
  ? ["server/**/*.go", "server/*.go", "**/auth*.go", "**/middleware/**/*.go", "SECURITY.md"]
720
721
  : ["server/**", "**/auth*", "**/middleware/**", "SECURITY.md"],
722
+ fallbackGlobsFor: (info) => [info.sourceGlob],
721
723
  systemPrompt: () => "You are a security engineer performing a first-pass review of a codebase. " +
722
724
  "Given source files, identify potential security issues — focusing on " +
723
725
  "authentication, authorization, input validation, TLS, secrets handling, " +
@@ -918,7 +920,7 @@ const SOURCE_SPECS = {
918
920
  * file list with uncommitted contents and never saw an untracked file (#248).
919
921
  * A target that is not a git repository gets a bounded walk.
920
922
  */
921
- async function listRepoFiles(targetDir) {
923
+ export async function listRepoFiles(targetDir) {
922
924
  try {
923
925
  const listed = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
924
926
  const deleted = await execFileAsync("git", ["-C", targetDir, "ls-files", "-z", "--deleted"], {
@@ -1198,7 +1200,7 @@ function matchesAnyGlob(path, globs) {
1198
1200
  }
1199
1201
  return false;
1200
1202
  }
1201
- function isSlurpable(relPath) {
1203
+ export function isSlurpable(relPath) {
1202
1204
  // A credential store is never a lens input, whatever its globs say (#252).
1203
1205
  if (isSecretFile(relPath))
1204
1206
  return false;
@@ -1237,8 +1239,7 @@ function resolveSliceMode(lens, files, totalChars) {
1237
1239
  return lens.sliceBy;
1238
1240
  return totalChars > lens.maxChars ? "directory" : "none";
1239
1241
  }
1240
- function collectLensFiles(allFiles, lens, info) {
1241
- const globs = lens.globsFor(info).filter(Boolean);
1242
+ function collectFilesMatching(allFiles, lens, globs) {
1242
1243
  if (globs.length === 0)
1243
1244
  return [];
1244
1245
  const out = [];
@@ -1253,6 +1254,30 @@ function collectLensFiles(allFiles, lens, info) {
1253
1254
  }
1254
1255
  return out;
1255
1256
  }
1257
+ /**
1258
+ * The files a lens will read: its targeted globs, or — when those match
1259
+ * nothing and the lens declares a fallback — the fallback globs, with a
1260
+ * sentence saying so (#319). The sentence travels to the estimate, the
1261
+ * batch entry, and the prompt, so a fallback scan is never a silent one.
1262
+ */
1263
+ export function selectLensFiles(allFiles, lens, info) {
1264
+ const globs = lens.globsFor(info).filter(Boolean);
1265
+ const targeted = collectFilesMatching(allFiles, lens, globs);
1266
+ if (targeted.length > 0 || globs.length === 0 || !lens.fallbackGlobsFor)
1267
+ return { files: targeted };
1268
+ const fallbackGlobs = lens.fallbackGlobsFor(info).filter(Boolean);
1269
+ const files = collectFilesMatching(allFiles, lens, fallbackGlobs);
1270
+ if (files.length === 0)
1271
+ return { files };
1272
+ return {
1273
+ files,
1274
+ fallback: `no files matched ${globs.join(", ")}${lens.skipTestFiles ? " (test files excluded)" : ""}; ` +
1275
+ `scanned all ${info.language} sources (${fallbackGlobs.join(", ")}) instead`,
1276
+ };
1277
+ }
1278
+ function collectLensFiles(allFiles, lens, info) {
1279
+ return selectLensFiles(allFiles, lens, info).files;
1280
+ }
1256
1281
  async function slurpFileList(targetDir, files, maxChars, redact = true) {
1257
1282
  const slices = [];
1258
1283
  let currentModule = "";
@@ -1329,16 +1354,18 @@ export async function gatherSlices(targetDir, lens, info, opts = {}) {
1329
1354
  return [{ moduleName: "root", content: "", fileCount: 0, chars: 0, files: [] }];
1330
1355
  }
1331
1356
  const { files: allFiles } = await listRepoFiles(targetDir);
1332
- const files = collectLensFiles(allFiles, lens, info);
1357
+ const { files, fallback } = selectLensFiles(allFiles, lens, info);
1333
1358
  const totalChars = await sumFileSizes(targetDir, files);
1334
1359
  const mode = resolveSliceMode(lens, files, totalChars);
1335
- if (mode === "none") {
1360
+ const slices = mode === "none"
1336
1361
  // Whole-repo slice: one module named after the repo, so a small
1337
1362
  // repo produces a single request instead of one per directory.
1338
- const single = files.map((f) => ({ ...f, moduleName: info.name }));
1339
- return slurpFileList(targetDir, single, lens.maxChars, redact);
1340
- }
1341
- return slurpFileList(targetDir, files, lens.maxChars, redact);
1363
+ ? await slurpFileList(targetDir, files.map((f) => ({ ...f, moduleName: info.name })), lens.maxChars, redact)
1364
+ : await slurpFileList(targetDir, files, lens.maxChars, redact);
1365
+ if (fallback)
1366
+ for (const slice of slices)
1367
+ slice.fallback = fallback;
1368
+ return slices;
1342
1369
  }
1343
1370
  async function sumFileSizes(targetDir, files) {
1344
1371
  let total = 0;
@@ -1362,7 +1389,17 @@ export function buildBatchRequest(lens, info, slice, index, sliceCount, model =
1362
1389
  model,
1363
1390
  messages: [
1364
1391
  { role: "system", content: lens.systemPrompt(info) },
1365
- { role: "user", content: lens.userPrompt(info, slice.content, slice.moduleName) },
1392
+ {
1393
+ role: "user",
1394
+ content:
1395
+ // A fallback scan is not "server source files": say what it is,
1396
+ // so the model judges the trust boundary wherever it appears
1397
+ // and does not report the missing server/ as a finding (#319).
1398
+ (slice.fallback
1399
+ ? `NOTE: this repository has no files under the paths this lens usually reads (${slice.fallback}). ` +
1400
+ "What follows is every source file it has; locate the trust boundary and the request-handling code wherever they live.\n\n"
1401
+ : "") + lens.userPrompt(info, slice.content, slice.moduleName),
1402
+ },
1366
1403
  ],
1367
1404
  response_format: { type: "json_schema", json_schema: SCHEMAS[lens.schemaName] },
1368
1405
  max_tokens: maxTokensOverride ?? lens.maxTokens,
@@ -1587,6 +1624,10 @@ export async function persistBroadsideRunMerging(broadsideDir, run) {
1587
1624
  run.triage = onDisk.triage;
1588
1625
  if (retryEntryRank(onDisk.retry) > retryEntryRank(run.retry))
1589
1626
  run.retry = onDisk.retry;
1627
+ // A verification pass another process recorded is never dropped by
1628
+ // a collect that never knew about it; a newer pass replaces an older.
1629
+ if (onDisk.verify && (!run.verify || onDisk.verify.at > run.verify.at))
1630
+ run.verify = onDisk.verify;
1590
1631
  for (const [lensId, theirs] of Object.entries(onDisk.batches)) {
1591
1632
  if (theirs && batchEntryRank(theirs) > batchEntryRank(run.batches[lensId]))
1592
1633
  run.batches[lensId] = theirs;
@@ -2320,11 +2361,14 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2320
2361
  slicesByLens.set(lensId, slices);
2321
2362
  if (slices.length === 0) {
2322
2363
  const globs = lens.globsFor(info).filter(Boolean);
2364
+ const fallbackGlobs = lens.fallbackGlobsFor?.(info).filter(Boolean) ?? [];
2323
2365
  skipReasons.set(lensId, globs.length === 0
2324
2366
  ? "the lens has no file patterns for this language"
2325
2367
  : matchedBeforeIncremental > 0
2326
2368
  ? "incremental: none of this lens's files changed since the previous run"
2327
- : `no files matched ${globs.join(", ")}${lens.skipTestFiles ? " (test files excluded)" : ""}`);
2369
+ : `no files matched ${globs.join(", ")}` +
2370
+ (fallbackGlobs.length > 0 ? ` or the fallback ${fallbackGlobs.join(", ")}` : "") +
2371
+ (lens.skipTestFiles ? " (test files excluded)" : ""));
2328
2372
  }
2329
2373
  const lensModel = modelForLens(lensId);
2330
2374
  const { pricing: lensPricing, outputCap: lensOutputCap } = resolved.get(lensModel);
@@ -2347,15 +2391,19 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2347
2391
  const approved = await opts.confirm({
2348
2392
  model,
2349
2393
  pricing,
2350
- lenses: perLensEstimate.map(({ lens, cost, maxTokens, lensModel, lensPricing }) => ({
2351
- lensId: lens.id,
2352
- name: lens.name,
2353
- slices: (slicesByLens.get(lens.id) ?? []).length,
2354
- maxTokens,
2355
- cost,
2356
- model: lensModel,
2357
- pricing: lensPricing,
2358
- })),
2394
+ lenses: perLensEstimate.map(({ lens, cost, maxTokens, lensModel, lensPricing }) => {
2395
+ const fallback = (slicesByLens.get(lens.id) ?? []).find((slice) => slice.fallback)?.fallback;
2396
+ return {
2397
+ lensId: lens.id,
2398
+ name: lens.name,
2399
+ slices: (slicesByLens.get(lens.id) ?? []).length,
2400
+ maxTokens,
2401
+ cost,
2402
+ model: lensModel,
2403
+ pricing: lensPricing,
2404
+ ...(fallback && { fallback }),
2405
+ };
2406
+ }),
2359
2407
  mixedModels: perLensEstimate.some(({ lensModel }) => lensModel !== model),
2360
2408
  totalCost: estimatedTotalCost,
2361
2409
  inputTokens: estimatedInputTokens,
@@ -2419,12 +2467,15 @@ export async function runBroadsideSubmit(cwd, apiKey, opts = {}) {
2419
2467
  const requests = slices.map((sl, i) => buildBatchRequest(lens, info, sl, i, slices.length, lensModel, maxTokens, config.reasoning ?? undefined));
2420
2468
  for (const request of requests)
2421
2469
  requestsByCustomId[request.custom_id] = request;
2470
+ const fallback = slices.find((slice) => slice.fallback)?.fallback;
2422
2471
  const entry = {
2423
2472
  batchId: "",
2424
2473
  requests: requests.length,
2425
2474
  status: "submitting",
2426
2475
  submittedAt: new Date().toISOString(),
2427
2476
  estimatedCost: priced.cost,
2477
+ // A scan of the fallback scope is recorded as such (#319).
2478
+ ...(fallback && { fallback }),
2428
2479
  // Recorded per lens so collect's truncation retry re-submits against
2429
2480
  // the model and ceiling this lens actually used, not the run default.
2430
2481
  ...(lensModel !== model && { model: lensModel }),
@@ -3256,7 +3307,9 @@ export function estimateSubmitText(result, lenses) {
3256
3307
  ? ` — ${explainBatchError(entry.error)}`
3257
3308
  : !entry.batchId && entry.reason
3258
3309
  ? ` — ${entry.reason}`
3259
- : "";
3310
+ : entry.fallback
3311
+ ? ` — ${entry.fallback}`
3312
+ : "";
3260
3313
  lines.push(` ${lens.name}: ${status} (${entry.requests} request(s), ~$${entry.estimatedCost.toFixed(4)})${override}${reason}`);
3261
3314
  }
3262
3315
  if (result.repo) {
@@ -3492,10 +3545,13 @@ export function statusText(state) {
3492
3545
  if (!entry)
3493
3546
  continue;
3494
3547
  lines.push(` ${lensId}: ${entry.status}${entry.batchId ? ` (${entry.batchId})` : ""}${entry.cost !== undefined ? `, $${entry.cost.toFixed(6)}` : ""}` +
3495
- (entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : ""));
3548
+ (entry.status === "skipped" && entry.reason ? ` — ${entry.reason}` : entry.fallback ? ` — ${entry.fallback}` : ""));
3496
3549
  }
3497
3550
  lines.push(` synthesis: ${run.synthesis.status}`);
3498
3551
  lines.push(` triage: ${run.triage?.status ?? "pending"}`);
3552
+ if (run.verify) {
3553
+ lines.push(` verify: ${run.verify.status} — ${run.verify.confirmed} confirmed of ${run.verify.verified} read on ${run.verify.model}, $${run.verify.cost.toFixed(4)}`);
3554
+ }
3499
3555
  if (run.totalCost !== undefined)
3500
3556
  lines.push(` total cost: $${run.totalCost.toFixed(6)}`);
3501
3557
  }
@@ -16,5 +16,6 @@ export * from "./dashboard.ts";
16
16
  export * from "./library.ts";
17
17
  export * from "./synthesis.ts";
18
18
  export * from "./broadside.ts";
19
+ export * from "./broadside-verify.ts";
19
20
  export * from "./secrets.ts";
20
21
  export * from "./dashboard-writer.ts";
@@ -19,5 +19,6 @@ export * from "./dashboard.js";
19
19
  export * from "./library.js";
20
20
  export * from "./synthesis.js";
21
21
  export * from "./broadside.js";
22
+ export * from "./broadside-verify.js";
22
23
  export * from "./secrets.js";
23
24
  export * from "./dashboard-writer.js";
@@ -1,5 +1,5 @@
1
1
  import { type BroadsideLensId } from "../../core/index.ts";
2
- export type BroadsideAction = "submit" | "collect" | "status" | "models";
2
+ export type BroadsideAction = "submit" | "collect" | "status" | "models" | "verify";
3
3
  export interface BroadsideFlags {
4
4
  action: BroadsideAction;
5
5
  /** Empty means "the repository's default lens set". */
@@ -22,11 +22,13 @@ export interface BroadsideFlags {
22
22
  model?: string;
23
23
  /** For submit: per-lens model overrides, layered over config.yaml's (#141). */
24
24
  lensModels?: Partial<Record<BroadsideLensId, string>>;
25
+ /** For verify: how many findings to read (#143). */
26
+ top?: number;
25
27
  benchmarks: boolean;
26
28
  unknown: string[];
27
29
  /** Set on an invalid combination. The caller surfaces it as an error. */
28
30
  error?: string;
29
31
  }
30
32
  /** Every token the completer offers, in the order it offers them. */
31
- export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--no-incremental", "--max-cost=", "--wait=", "--run=", "--model=", "--lens-model=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
33
+ export declare const KNOWN_BROADSIDE_TOKENS: readonly ["submit", "collect", "status", "models", "verify", "architecture", "api", "security", "defect", "conventions", "porting", "--incremental", "--no-incremental", "--max-cost=", "--wait=", "--run=", "--model=", "--lens-model=", "--top=", "--no-synthesis", "--no-triage", "--no-retry-truncated", "--benchmarks"];
32
34
  export declare function parseBroadsideFlags(args: string): BroadsideFlags;
@@ -6,6 +6,7 @@
6
6
  // /codecarto-broadside collect --wait=900
7
7
  // /codecarto-broadside status
8
8
  // /codecarto-broadside models --benchmarks
9
+ // /codecarto-broadside verify --top=10 → read the top findings against the source
9
10
  //
10
11
  // Flags mirror the codecarto_broadside tool parameters, with the negative
11
12
  // forms spelled out because a slash command has no place to pass `false`:
@@ -14,8 +15,9 @@
14
15
  // --max-cost=N --no-retry-truncated
15
16
  // --wait=SECONDS --benchmarks (models only)
16
17
  // --run=ID (collect only: an older run, as listed by status)
17
- // --model=ID (submit only: the run's batch model, as listed by models)
18
+ // --model=ID (submit: the run's batch model, as listed by models; verify: the sync model to read with)
18
19
  // --lens-model=LENS:ID (submit only, repeatable: one lens on its own model)
20
+ // --top=N (verify only: how many findings to read, most severe first)
19
21
  //
20
22
  // A model id itself contains a colon (`vendor/name:batch`), so --lens-model
21
23
  // splits on the first colon only: `security:deepseek/deepseek-v4-pro:batch`.
@@ -28,13 +30,14 @@
28
30
  // The parser never throws. index.ts decides how to surface unknown tokens and
29
31
  // invalid combinations, matching parseNextFlags.
30
32
  import { BROADSIDE_LENS_IDS } from "../../core/index.js";
31
- const ACTIONS = new Set(["submit", "collect", "status", "models"]);
33
+ const ACTIONS = new Set(["submit", "collect", "status", "models", "verify"]);
32
34
  /** Every token the completer offers, in the order it offers them. */
33
35
  export const KNOWN_BROADSIDE_TOKENS = [
34
36
  "submit",
35
37
  "collect",
36
38
  "status",
37
39
  "models",
40
+ "verify",
38
41
  ...BROADSIDE_LENS_IDS,
39
42
  "--incremental",
40
43
  "--no-incremental",
@@ -43,6 +46,7 @@ export const KNOWN_BROADSIDE_TOKENS = [
43
46
  "--run=",
44
47
  "--model=",
45
48
  "--lens-model=",
49
+ "--top=",
46
50
  "--no-synthesis",
47
51
  "--no-triage",
48
52
  "--no-retry-truncated",
@@ -122,6 +126,14 @@ export function parseBroadsideFlags(args) {
122
126
  result.runId = value || undefined;
123
127
  continue;
124
128
  }
129
+ if (token.startsWith("--top=")) {
130
+ const value = parseNumeric(token, "--top", result);
131
+ if (value !== undefined && (!Number.isInteger(value) || value < 1))
132
+ result.error ??= `--top needs a positive whole number (got "${token.slice("--top=".length)}").`;
133
+ else if (value !== undefined)
134
+ result.top = value;
135
+ continue;
136
+ }
125
137
  if (token.startsWith("--model=")) {
126
138
  const value = token.slice("--model=".length).trim();
127
139
  // An empty value is a mistyped selection, not "use the default":
@@ -167,11 +179,17 @@ export function parseBroadsideFlags(args) {
167
179
  if (result.action === "status" && result.waitSeconds !== undefined) {
168
180
  result.error ??= "--wait is only meaningful for submit and collect; status reads recorded state.";
169
181
  }
170
- if (result.runId !== undefined && result.action !== "collect") {
171
- result.error ??= `--run is only meaningful for collect (got action "${result.action}").`;
182
+ if (result.runId !== undefined && result.action !== "collect" && result.action !== "verify") {
183
+ result.error ??= `--run is only meaningful for collect and verify (got action "${result.action}").`;
184
+ }
185
+ if (result.model !== undefined && result.action !== "submit" && result.action !== "verify") {
186
+ result.error ??= `--model is only meaningful for submit and verify (got action "${result.action}").`;
187
+ }
188
+ if (result.top !== undefined && result.action !== "verify") {
189
+ result.error ??= `--top is only meaningful for verify (got action "${result.action}").`;
172
190
  }
173
- if (result.model !== undefined && result.action !== "submit") {
174
- result.error ??= `--model is only meaningful for submit (got action "${result.action}").`;
191
+ if (result.action === "verify" && result.waitSeconds !== undefined) {
192
+ result.error ??= "--wait is only meaningful for submit and collect; verify runs to completion.";
175
193
  }
176
194
  if (result.lensModels !== undefined && result.action !== "submit") {
177
195
  result.error ??= `--lens-model is only meaningful for submit (got action "${result.action}").`;
@@ -10,7 +10,7 @@ import { completeLastToken } from "./completions.js";
10
10
  import { buildPiGuideMessage } from "./guide-framing.js";
11
11
  import { isCtxLive, notifyCtx } from "./notify.js";
12
12
  import { phaseCompactionExtension } from "./phase-compaction.js";
13
- import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, expandTilde, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, statusText, describeConfigProblems, describeIncrementalFallback, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../../core/index.js";
13
+ import { applyAmendment, buildPhasePrompt, buildSkillPrompt, buildValidationSummary, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, computePerPhaseTotals, computeTotals, ConfidentialityMismatchError, createEmptyStatus, DEFAULT_PIPELINE_PATH, describeScaffoldStaleness, deriveSlug, discoverLibrary, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, expandTilde, getPipelineLabel, getWorkspaceState, isWithinPath, resolveExistingPrefix, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, BroadsideCancelledError, broadsideDirFor, collectResultText, estimateSubmitText, getLens, listAmendmentNames, listBatchModels, listGuideTopics, listScaffoldRefreshFiles, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadAmendmentFile, loadBroadsideConfig, modelsText, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, runBroadsideVerify, verifyResultText, statusText, describeConfigProblems, describeIncrementalFallback, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, packagedWorkspaceDir, pathExists, PACKAGE_VERSION, readBroadsideSkill, readGuide, refreshScaffold, PhasePreflightError, PIPELINE_ALIASES, publishEntry, resolvePhase, resolvePipelineChoice, resolvePublishSourceRepo, SourceRepoMismatchError, runPhasePreflight, SCAFFOLD_REFRESH_PROTECTED, seedOrchestratorFiles, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../../core/index.js";
14
14
  import { initLibrary } from "../../core/library.js";
15
15
  import { resolveUserConfigPath } from "../../core/orchestrator-config.js";
16
16
  const STATUS_WIDGET_ID = "codecarto-widget";
@@ -130,11 +130,14 @@ function describeBroadsideEstimate(estimate) {
130
130
  `Rates: $${estimate.pricing.inputPerM.toFixed(4)}/M in · $${estimate.pricing.outputPerM.toFixed(4)}/M out`,
131
131
  "",
132
132
  "Per lens:",
133
- ...estimate.lenses.map(({ name, slices, cost, model }) => {
133
+ ...estimate.lenses.map(({ name, slices, cost, model, fallback }) => {
134
134
  // Naming the model only when it differs keeps the common case quiet
135
135
  // and makes a mixed-model run impossible to approve without noticing.
136
136
  const override = estimate.mixedModels && model !== estimate.model ? ` on ${model}` : "";
137
- return ` ${name}: ${slices} slice${slices === 1 ? "" : "s"} — ~$${cost.toFixed(4)}${override}`;
137
+ // A lens priced on its fallback scope says so here, where the
138
+ // spend is approved — every source file, not a server directory (#319).
139
+ const scope = fallback ? `\n ↳ ${fallback}` : "";
140
+ return ` ${name}: ${slices} slice${slices === 1 ? "" : "s"} — ~$${cost.toFixed(4)}${override}${scope}`;
138
141
  }),
139
142
  "",
140
143
  `Estimated total: ~$${estimate.totalCost.toFixed(4)} ` +
@@ -992,7 +995,7 @@ export default function codeCartographerExtension(pi) {
992
995
  },
993
996
  });
994
997
  pi.registerCommand("codecarto-broadside", {
995
- description: "Batch reconnaissance (Broad-Side): /codecarto-broadside [submit|collect|status|models] [lenses…] [--model=ID] [--lens-model=LENS:ID] [flags]",
998
+ description: "Batch reconnaissance (Broad-Side): /codecarto-broadside [submit|collect|status|models|verify] [lenses…] [--model=ID] [--lens-model=LENS:ID] [--top=N] [flags]",
996
999
  // Completes the token under the cursor, so lens names and flags are
997
1000
  // offered after the action too, and keeps everything typed before it.
998
1001
  getArgumentCompletions: (prefix) => completeLastToken(prefix, KNOWN_BROADSIDE_TOKENS.map((value) => ({ value }))),
@@ -1178,6 +1181,35 @@ export default function codeCartographerExtension(pi) {
1178
1181
  }
1179
1182
  return;
1180
1183
  }
1184
+ if (flags.action === "verify") {
1185
+ // The verification pass (#143): one sync call per finding with
1186
+ // read-only tools; the widget counts verdicts as they land.
1187
+ const tally = { done: 0 };
1188
+ renderProgress("Reading the top findings against the source…");
1189
+ try {
1190
+ const verified = await runBroadsideVerify(ctx.cwd, apiKey, {
1191
+ ...(flags.runId && { runId: flags.runId }),
1192
+ ...(flags.top !== undefined && { top: flags.top }),
1193
+ ...(flags.model && { model: flags.model }),
1194
+ maxCost: flags.maxCost ?? config.maxCost,
1195
+ signal: ctx.signal,
1196
+ onProgress: (finding) => {
1197
+ tally.done += 1;
1198
+ progress.set(`#${finding.index}`, `${finding.verdict} — ${finding.title}`);
1199
+ renderProgress(`Verifying findings… ${tally.done} read`);
1200
+ },
1201
+ });
1202
+ const lines = verifyResultText(verified).split("\n");
1203
+ const confirmed = verified.findings.filter((f) => f.verdict === "confirmed").length;
1204
+ finish(lines, `Broad-Side verify: ${confirmed} confirmed of ${verified.findings.length} read`, verified.status === "completed" ? "info" : "warning");
1205
+ }
1206
+ catch (error) {
1207
+ if (ctx.hasUI)
1208
+ ctx.ui.setWidget(BROADSIDE_WIDGET_ID, undefined);
1209
+ notifyCtx(ctx, `Broad-Side verify failed: ${error instanceof Error ? error.message : String(error)}`, "error");
1210
+ }
1211
+ return;
1212
+ }
1181
1213
  // action === "collect"
1182
1214
  renderProgress("Polling batches…");
1183
1215
  try {
@@ -233,7 +233,7 @@ export declare function handleAmend(args: {
233
233
  }>;
234
234
  export declare function handleBroadside(args: {
235
235
  cwd: string;
236
- action: "submit" | "collect" | "status" | "models";
236
+ action: "submit" | "collect" | "status" | "models" | "verify";
237
237
  lenses?: string[];
238
238
  api_key?: string;
239
239
  wait_seconds?: number;
@@ -247,6 +247,7 @@ export declare function handleBroadside(args: {
247
247
  incremental?: boolean;
248
248
  model?: string;
249
249
  lens_models?: Record<string, string>;
250
+ top?: number;
250
251
  }): Promise<{
251
252
  content: {
252
253
  type: "text";
@@ -17,7 +17,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
17
17
  import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js";
18
18
  import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
19
19
  import { basename, isAbsolute, join } from "node:path";
20
- import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, previewPublishVersion, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, readBroadsideSkill, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, seedOrchestratorFiles, statusLineWriter, statusText, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../core/index.js";
20
+ import { buildPhasePrompt, buildSkillPrompt, buildValidationSummary, BROADSIDE_DIR, broadsideDirFor, BROADSIDE_LENS_IDS, BROADSIDE_SKILL_NAME, BroadsideConfigError, defaultBroadsideConfig, backupWorkspaceState, canonicalPath, copyPackagedWorkspace, collectResultText, completeValidatedPhase, computePerPhaseTotals, computeTotals, createEmptyStatus, DEFAULT_PIPELINE_PATH, deriveSlug, discoverLibrary, describeScaffoldStaleness, detectProvenanceConflicts, estimateSubmitText, getLens, describeDanglingCarryForward, describeMissingCompletedOutputs, describeStuckPipeline, getPipelineLabel, getWorkspaceState, isValidSlug, isWithinPathResolved, listBatchModels, listEntries, listGuideTopics, loadBroadsideConfig, modelsText, readGuide, listMissingCompletedOutputs, listSkillNames, resolvePipelineOutcome, resolveSkillName, loadCodecartoConfig, loadUsage, loadYamlFile, normalizeForComparison, PACKAGE_VERSION, packagedWorkspaceDir, pathExists, PhasePreflightError, previewPublishVersion, publishEntry, reindex as libraryReindex, refreshScaffold, resolvePhase, resolvePipelineChoice, readBroadsideSkill, runBroadsideCollect, runBroadsideStatus, runBroadsideSubmit, runBroadsideVerify, verifyResultText, seedOrchestratorFiles, statusLineWriter, statusText, stringifySimpleYaml, switchPipeline, validatePhaseOutput, writeLibraryConfig, writeDashboard, } from "../core/index.js";
21
21
  import { applyAmendment } from "../core/amendment.js";
22
22
  import { appendUsageRun } from "../core/usage.js";
23
23
  import { initLibrary } from "../core/library.js";
@@ -1037,8 +1037,8 @@ function resolveBroadsideApiKey(explicit, config) {
1037
1037
  export async function handleBroadside(args) {
1038
1038
  const cwd = await validateCwd(args.cwd);
1039
1039
  const action = args.action ?? "submit";
1040
- if (!["submit", "collect", "status", "models"].includes(action)) {
1041
- throw new McpError(ErrorCode.InvalidParams, `Unknown action: ${action}. Valid actions: submit, collect, status, models.`);
1040
+ if (!["submit", "collect", "status", "models", "verify"].includes(action)) {
1041
+ throw new McpError(ErrorCode.InvalidParams, `Unknown action: ${action}. Valid actions: submit, collect, status, models, verify.`);
1042
1042
  }
1043
1043
  // A config.yaml that exists but cannot be read refuses every action that
1044
1044
  // would act on it (#232); status only reads recorded runs, so it answers
@@ -1173,8 +1173,40 @@ export async function handleBroadside(args) {
1173
1173
  maxCost: result.maxCost,
1174
1174
  });
1175
1175
  }
1176
- // action === "collect"
1177
1176
  const runId = typeof args.run_id === "string" && args.run_id.trim() ? args.run_id.trim() : undefined;
1177
+ if (action === "verify") {
1178
+ // The verification pass (#143): one sync call per finding with read-only
1179
+ // tools, most severe first. `max_cost` is a running cap here — a sync
1180
+ // call's cost is known only when it returns — so the pass stops before
1181
+ // the next finding once reached; absent, config.yaml's cap applies.
1182
+ if (args.top !== undefined && !(typeof args.top === "number" && Number.isInteger(args.top) && args.top >= 1)) {
1183
+ throw new McpError(ErrorCode.InvalidParams, "top must be a positive integer.");
1184
+ }
1185
+ if (args.model !== undefined && !(typeof args.model === "string" && args.model.trim())) {
1186
+ throw new McpError(ErrorCode.InvalidParams, "model must be a non-empty OpenRouter model id.");
1187
+ }
1188
+ const maxCost = typeof args.max_cost === "number" && args.max_cost >= 0 ? args.max_cost : config.maxCost;
1189
+ const verified = await runBroadsideVerify(cwd, apiKey, {
1190
+ ...(runId && { runId }),
1191
+ ...(args.top !== undefined && { top: args.top }),
1192
+ ...(typeof args.model === "string" && args.model.trim() && { model: args.model.trim() }),
1193
+ maxCost,
1194
+ signal: serverLifetime?.signal,
1195
+ }).catch((error) => {
1196
+ throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
1197
+ });
1198
+ return textResult(verifyResultText(verified), {
1199
+ runId: verified.runId,
1200
+ outputDir: verified.outputDir,
1201
+ status: verified.status,
1202
+ model: verified.model,
1203
+ candidates: verified.candidates,
1204
+ totalCost: verified.totalCost,
1205
+ ...(verified.stoppedByCost && { stoppedByCost: true }),
1206
+ findings: verified.findings,
1207
+ });
1208
+ }
1209
+ // action === "collect"
1178
1210
  const collect = await runBroadsideCollect(cwd, apiKey, {
1179
1211
  waitMs,
1180
1212
  includeSynthesis,
@@ -1502,8 +1534,8 @@ const TOOLS = [
1502
1534
  cwd: { type: "string", description: "Absolute path to the target repository." },
1503
1535
  action: {
1504
1536
  type: "string",
1505
- enum: ["submit", "collect", "status", "models"],
1506
- description: "submit fires all lens batches and returns batch ids; collect polls submitted batches, saves results, and optionally runs the synthesis pass; status shows recorded runs; models lists batch-capable models with pricing and capabilities.",
1537
+ enum: ["submit", "collect", "status", "models", "verify"],
1538
+ description: "submit fires all lens batches and returns batch ids; collect polls submitted batches, saves results, and optionally runs the synthesis pass; status shows recorded runs; models lists batch-capable models with pricing and capabilities; verify reads a collected run's top defect and security findings against the repository with read-only tools (one sync-priced call each, about a cent on the default model) and writes verified.md/verified.json beside triage.md with a verdict per finding: confirmed (a reachable failure, with the trigger), not-a-defect, discarded, or unclear.",
1507
1539
  },
1508
1540
  lenses: {
1509
1541
  type: "array",
@@ -1516,7 +1548,11 @@ const TOOLS = [
1516
1548
  },
1517
1549
  run_id: {
1518
1550
  type: "string",
1519
- description: "For collect: the run to collect, as listed by the status action. Defaults to the most recent run; pass this to collect an older run that is still in flight after a newer submit.",
1551
+ description: "For collect and verify: the run to act on, as listed by the status action. Defaults to the most recent run; pass this to collect an older run that is still in flight after a newer submit.",
1552
+ },
1553
+ top: {
1554
+ type: "integer",
1555
+ description: "For verify: how many findings to read, most severe first (default 10). Each costs one sync call; max_cost caps the pass as a running total.",
1520
1556
  },
1521
1557
  wait_seconds: {
1522
1558
  type: "number",
@@ -1536,7 +1572,7 @@ const TOOLS = [
1536
1572
  },
1537
1573
  max_cost: {
1538
1574
  type: "number",
1539
- description: "Approximate run expense limit in USD. The submit action estimates the run cost from slice sizes and the configured model's per-token pricing (live OpenRouter lookup, cached 24h) and refuses to submit when the estimate exceeds the limit unless force is true. Falls back to max_cost in .codecarto/broadside/config.yaml, whose default is $1.00; pass 0 for no limit.",
1575
+ description: "Approximate run expense limit in USD. The submit action estimates the run cost from slice sizes and the configured model's per-token pricing (live OpenRouter lookup, cached 24h) and refuses to submit when the estimate exceeds the limit unless force is true. For verify it is a running cap: the pass stops before the next finding once the calls so far have reached it. Falls back to max_cost in .codecarto/broadside/config.yaml, whose default is $1.00; pass 0 for no limit.",
1540
1576
  },
1541
1577
  force: {
1542
1578
  type: "boolean",
@@ -1552,7 +1588,7 @@ const TOOLS = [
1552
1588
  },
1553
1589
  model: {
1554
1590
  type: "string",
1555
- description: "For submit: the OpenRouter batch model for this run (an id ending in :batch, as listed by action 'models'). Falls back to model in .codecarto/broadside/config.yaml, then the shipped default. Pre-flighted like the configured model: priced from the live catalog, refused without structured-output support, clamped to its completion ceiling. The models listing is advisory — some catalog ids have no batch endpoint and are refused at submit, at no cost; the listing tags ids this repository has already seen accepted or refused.",
1591
+ description: "For verify: the sync (non-batch) OpenRouter model to read with; defaults to the run's model without its :batch suffix. For submit: the OpenRouter batch model for this run (an id ending in :batch, as listed by action 'models'). Falls back to model in .codecarto/broadside/config.yaml, then the shipped default. Pre-flighted like the configured model: priced from the live catalog, refused without structured-output support, clamped to its completion ceiling. The models listing is advisory — some catalog ids have no batch endpoint and are refused at submit, at no cost; the listing tags ids this repository has already seen accepted or refused.",
1556
1592
  },
1557
1593
  lens_models: {
1558
1594
  type: "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codecartographer-pi",
3
- "version": "0.22.3",
3
+ "version": "0.24.0",
4
4
  "mcpName": "io.github.HuginnIndustries/codecartographer",
5
5
  "description": "Turn an unfamiliar codebase into a validated reimplementation spec, then synthesize confirmed specs and a product vision into a traceable plan.",
6
6
  "type": "module",