@applesnort/crosscheck 0.2.2 → 0.6.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.
package/lib/cache.mjs ADDED
@@ -0,0 +1,100 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Result cache.
5
+ //
6
+ // A panel that re-reviews unchanged files on every run costs money for nothing,
7
+ // and a tool that costs money for nothing gets switched off. Caching is keyed on
8
+ // everything that could change the answer: the lens definition, the files, their
9
+ // contents, and the prompt options.
10
+ //
11
+ // The lens definition is part of the key on purpose. Editing a lens must
12
+ // invalidate its cached results — a cache that survives a prompt change would
13
+ // quietly serve answers from the old lens and there would be no way to tell.
14
+ //
15
+ // Pure key computation; IO is injected so this is testable without a disk.
16
+
17
+ // FNV-1a over the inputs. Not cryptographic: it only has to distinguish inputs
18
+ // within one project, and a collision costs a stale result rather than a
19
+ // security failure.
20
+ export function digest(parts) {
21
+ let h = 0x811c9dc5;
22
+ for (const part of parts) {
23
+ const s = String(part);
24
+ for (let i = 0; i < s.length; i++) {
25
+ h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0;
26
+ }
27
+ // Separator, so ['ab','c'] and ['a','bc'] do not collide.
28
+ h = Math.imul(h ^ 0x1f, 0x01000193) >>> 0;
29
+ }
30
+ return h.toString(16).padStart(8, '0');
31
+ }
32
+
33
+ export const CACHE_VERSION = 1;
34
+
35
+ // files: [{path, content}] in the order the lens will see them.
36
+ export function cacheKey({ lens, definition, files, promptOptions = {} }) {
37
+ if (!lens) {
38
+ throw new Error('cacheKey requires a lens name');
39
+ }
40
+ return digest([
41
+ `v${CACHE_VERSION}`,
42
+ lens,
43
+ // The definition body decides what the lens does, so it decides the answer.
44
+ definition ?? '',
45
+ ...(files ?? []).flatMap(f => [f.path, f.content ?? '']),
46
+ JSON.stringify(promptOptions ?? {})
47
+ ]);
48
+ }
49
+
50
+ // entry: {key, lens, output, storedAt}
51
+ export function isUsableEntry(entry, key) {
52
+ return Boolean(
53
+ entry &&
54
+ entry.key === key &&
55
+ entry.version === CACHE_VERSION &&
56
+ typeof entry.output === 'string' &&
57
+ entry.output.length > 0);
58
+ }
59
+
60
+ export function makeEntry({ key, lens, output, now }) {
61
+ return {
62
+ version: CACHE_VERSION,
63
+ key,
64
+ lens,
65
+ // Recorded for a human reading the cache directory, not used for matching.
66
+ storedAt: now ?? null,
67
+ output
68
+ };
69
+ }
70
+
71
+ // A cache wired to injected IO. `read` returns a parsed entry or null; `write`
72
+ // persists one. Both may be omitted to disable caching entirely, which is what
73
+ // --no-cache does.
74
+ export function createCache({ read = null, write = null } = {}) {
75
+ const stats = { hits: 0, misses: 0, writes: 0, hitLenses: [] };
76
+ return {
77
+ stats,
78
+ enabled: Boolean(read || write),
79
+ get(key, lens) {
80
+ if (!read) {
81
+ return null;
82
+ }
83
+ const entry = read(key);
84
+ if (isUsableEntry(entry, key)) {
85
+ stats.hits += 1;
86
+ stats.hitLenses.push(lens);
87
+ return entry.output;
88
+ }
89
+ stats.misses += 1;
90
+ return null;
91
+ },
92
+ set(key, lens, output, now = null) {
93
+ if (!write || !output) {
94
+ return;
95
+ }
96
+ write(key, makeEntry({ key, lens, output, now }));
97
+ stats.writes += 1;
98
+ }
99
+ };
100
+ }
@@ -0,0 +1,138 @@
1
+ /*!
2
+ * Copyright (c) 2026 Joel Mangin. MIT License.
3
+ */
4
+ // Build a pull-request comment.
5
+ //
6
+ // Deliberately a summary, not per-line review comments. SARIF uploaded to code
7
+ // scanning already annotates the exact lines in the Files-changed view, and does
8
+ // it better than a bot posting threads. What that view cannot show is the shape of
9
+ // the run: which lenses were skipped, which died, what the baseline suppressed,
10
+ // what verification refuted. That is what this comment carries.
11
+ //
12
+ // Every comment embeds a marker so a later run can edit the previous one instead
13
+ // of stacking. A PR with eleven bot comments gets muted, and a muted reviewer
14
+ // finds nothing.
15
+
16
+ export const COMMENT_MARKER = '<!-- crosscheck:report -->';
17
+
18
+ const LEVEL_LABEL = { BLOCK: '🔴 BLOCK', FIX: '🟡 FIX', CONSIDER: '⚪ CONSIDER' };
19
+
20
+ function severitySection(findings, severity, { limit }) {
21
+ const group = findings.filter(f => f.severity === severity);
22
+ if (group.length === 0) {
23
+ return [];
24
+ }
25
+ const lines = [`### ${LEVEL_LABEL[severity]} (${group.length})`, ''];
26
+ for (const f of group.slice(0, limit)) {
27
+ const who = f.consensus
28
+ ? `**${f.lenses.join(' + ')}** agreed`
29
+ : `${f.lenses.join(', ')}`;
30
+ const collapsed = f.occurrences > f.lenses.length
31
+ ? ` · ${f.occurrences} reports across lines ${f.lines.join(', ')}`
32
+ : '';
33
+ lines.push(
34
+ `- \`${f.file}:${f.line}\` — ${f.issue}` +
35
+ (f.fix ? `\n **Fix:** ${f.fix}` : '') +
36
+ `\n <sub>${who}${collapsed}</sub>`);
37
+ }
38
+ if (group.length > limit) {
39
+ // Named, not hidden: a truncated list that does not say so reads as the
40
+ // whole list.
41
+ lines.push('', `_…and ${group.length - limit} more ${severity} finding(s) ` +
42
+ 'not shown here. The full set is in the SARIF output and the run log._');
43
+ }
44
+ lines.push('');
45
+ return lines;
46
+ }
47
+
48
+ // merged: the object from mergeFindings, optionally with the run's disclosures.
49
+ export function buildComment({
50
+ merged,
51
+ refuted = [],
52
+ suppressed = [],
53
+ dropped = [],
54
+ skipped = [],
55
+ target = null,
56
+ sarifPath = null,
57
+ limitPerSeverity = 10
58
+ } = {}) {
59
+ const findings = merged?.findings ?? [];
60
+ const incomplete = merged?.incomplete ?? [];
61
+ const counts = {
62
+ BLOCK: findings.filter(f => f.severity === 'BLOCK').length,
63
+ FIX: findings.filter(f => f.severity === 'FIX').length,
64
+ CONSIDER: findings.filter(f => f.severity === 'CONSIDER').length
65
+ };
66
+
67
+ const out = [COMMENT_MARKER, '', '## crosscheck'];
68
+
69
+ if (target) {
70
+ out.push('', `Reviewed ${target}.`);
71
+ }
72
+
73
+ if (findings.length === 0) {
74
+ out.push('', 'No findings.');
75
+ } else {
76
+ out.push('',
77
+ `**${counts.BLOCK} block · ${counts.FIX} fix · ${counts.CONSIDER} consider**` +
78
+ (findings.some(f => f.consensus)
79
+ ? ` · ${findings.filter(f => f.consensus).length} agreed by more than one lens`
80
+ : ''),
81
+ '');
82
+ for (const severity of ['BLOCK', 'FIX', 'CONSIDER']) {
83
+ out.push(...severitySection(findings, severity, { limit: limitPerSeverity }));
84
+ }
85
+ }
86
+
87
+ // The disclosures. Each one is a hole in the coverage, and a report that omits
88
+ // them reads as completeness that was never there. Zeroes are stated too.
89
+ const notes = [];
90
+ notes.push(`Refuted in verification: ${refuted.length}`);
91
+ if (incomplete.length) {
92
+ notes.push(`**Did not complete: ${incomplete.join(', ')}** — their coverage ` +
93
+ 'is missing from this report');
94
+ }
95
+ if (dropped.length) {
96
+ notes.push(`**Budget reached** — not run: ${dropped.map(d => d.lens ?? d).join(', ')}`);
97
+ }
98
+ if (suppressed.length) {
99
+ notes.push(`Suppressed by baseline: ${suppressed.length}`);
100
+ }
101
+ if (skipped.length) {
102
+ // Two different reasons — nothing in scope matched, or you excluded it — and
103
+ // calling an explicit exclusion "irrelevant" misstates the run.
104
+ const irrelevant = skipped.filter(s => !/--only|--skip/.test(s.reason ?? ''));
105
+ const excluded = skipped.filter(s => /--only|--skip/.test(s.reason ?? ''));
106
+ if (irrelevant.length) {
107
+ notes.push('Not applicable to these files: ' +
108
+ irrelevant.map(s => s.lens ?? s).join(', '));
109
+ }
110
+ if (excluded.length) {
111
+ notes.push('Excluded by request: ' +
112
+ excluded.map(s => s.lens ?? s).join(', '));
113
+ }
114
+ }
115
+ out.push('<details><summary>Run details</summary>', '');
116
+ for (const note of notes) {
117
+ out.push(`- ${note}`);
118
+ }
119
+ if (sarifPath) {
120
+ out.push('', `SARIF written to \`${sarifPath}\` — upload it to code scanning ` +
121
+ 'for per-line annotations in the Files changed view.');
122
+ }
123
+ out.push('', '</details>');
124
+
125
+ return out.join('\n') + '\n';
126
+ }
127
+
128
+ // True when a comment body was written by crosscheck, so a run can edit its own
129
+ // previous comment rather than adding another.
130
+ export function isOwnComment(body) {
131
+ return String(body ?? '').includes(COMMENT_MARKER);
132
+ }
133
+
134
+ // Given the comments on a PR, the id of ours to update, or null to create one.
135
+ export function findOwnComment(comments) {
136
+ const mine = (comments ?? []).filter(c => isOwnComment(c.body));
137
+ return mine.length ? mine[mine.length - 1].id ?? null : null;
138
+ }
package/lib/config.mjs CHANGED
@@ -23,7 +23,13 @@ export const CONFIG_FILENAMES = [
23
23
  // an error.
24
24
  export const CONFIG_KEYS = new Set([
25
25
  'exec', 'lenses', 'concurrency', 'only', 'skip', 'mixed',
26
- 'out', 'sarif', 'baseline', 'overlap'
26
+ 'out', 'sarif', 'baseline', 'overlap',
27
+ // Phase 1: change-scoped review, verification, and a project's own gate.
28
+ 'preflight', 'context', 'verify', 'no-verify', 'since',
29
+ // Phase 2: cost control.
30
+ 'max-dispatches', 'no-cache', 'cache-dir',
31
+ // Phase 3: CI integration.
32
+ 'comment-file'
27
33
  ]);
28
34
 
29
35
  const LIST_KEYS = new Set(['only', 'skip']);
@@ -60,18 +66,36 @@ export function validateConfig(raw, source = 'config') {
60
66
  }
61
67
  continue;
62
68
  }
63
- if (key === 'concurrency') {
69
+ // `exec` may be one command for every lens, or a map choosing per lens so a
70
+ // cheap lens does not pay for an expensive model.
71
+ if (key === 'exec' && value && typeof value === 'object' &&
72
+ !Array.isArray(value)) {
73
+ const bad = Object.entries(value)
74
+ .filter(([, v]) => typeof v !== 'string' || v === '');
75
+ if (bad.length) {
76
+ problems.push(
77
+ `${source}: exec map entries must be non-empty strings ` +
78
+ `(${bad.map(([k]) => k).join(', ')})`);
79
+ continue;
80
+ }
81
+ config.exec = { ...value };
82
+ continue;
83
+ }
84
+ if (key === 'concurrency' || key === 'context' || key === 'max-dispatches') {
64
85
  const n = Number(value);
65
- if (!Number.isInteger(n) || n < 1) {
66
- problems.push(`${source}: "concurrency" must be a positive integer`);
86
+ const floor = key === 'context' ? 0 : 1;
87
+ if (!Number.isInteger(n) || n < floor) {
88
+ problems.push(
89
+ `${source}: "${key}" must be an integer >= ${floor}`);
67
90
  continue;
68
91
  }
69
92
  config[key] = n;
70
93
  continue;
71
94
  }
72
- if (key === 'mixed') {
95
+ if (key === 'mixed' || key === 'verify' || key === 'no-verify' ||
96
+ key === 'no-cache') {
73
97
  if (typeof value !== 'boolean') {
74
- problems.push(`${source}: "mixed" must be true or false`);
98
+ problems.push(`${source}: "${key}" must be true or false`);
75
99
  continue;
76
100
  }
77
101
  config[key] = value;
package/lib/lenses.mjs CHANGED
@@ -198,3 +198,38 @@ export function applyOverrides(routed, { only, skip } = {}) {
198
198
  }
199
199
  return { roster, skipped };
200
200
  }
201
+
202
+ // Layered lens resolution.
203
+ //
204
+ // A single lens directory has to be either yours or the packaged one, which
205
+ // forces anyone adding a lens to fork all of them and lose upstream changes.
206
+ // Sources are layered instead, in increasing precedence: a later source with the
207
+ // same lens `name` shadows an earlier one, so overriding one lens costs one file
208
+ // rather than a fork.
209
+ //
210
+ // sources: [{origin, lenses: [{name, ...}]}] — origin is a label for reporting,
211
+ // usually the directory the lenses were read from.
212
+ export function resolveLensSet(sources) {
213
+ const byName = new Map();
214
+ const shadowed = [];
215
+ for (const source of sources ?? []) {
216
+ for (const lens of source.lenses ?? []) {
217
+ if (!lens?.name) {
218
+ continue;
219
+ }
220
+ const previous = byName.get(lens.name);
221
+ if (previous) {
222
+ shadowed.push({
223
+ name: lens.name,
224
+ winner: source.origin,
225
+ shadowedFrom: previous.origin
226
+ });
227
+ }
228
+ byName.set(lens.name, { ...lens, origin: source.origin });
229
+ }
230
+ }
231
+ return {
232
+ lenses: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)),
233
+ shadowed
234
+ };
235
+ }
package/lib/merge.mjs CHANGED
@@ -254,7 +254,13 @@ export function mergeFindings(reports, options = {}) {
254
254
  const finding = {
255
255
  ...rest,
256
256
  consensus: c.lenses.length > 1,
257
- consensusScore: consensusScore(c.lenses, overlap)
257
+ consensusScore: consensusScore(c.lenses, overlap),
258
+ // How many reports collapsed into this entry. Clustering is transitive —
259
+ // a run of similar findings on consecutive lines chains into one — so a
260
+ // collapse much larger than the lens count needs to be visible rather
261
+ // than looking like a single finding.
262
+ occurrences: members.length,
263
+ lines: [...new Set(members.map(m => m.line))].sort((a, b) => a - b)
258
264
  };
259
265
  // The key is derived after clustering, from the reported anchor and the
260
266
  // representative issue, so a baseline stays stable across runs.
package/lib/prompt.mjs CHANGED
@@ -14,6 +14,12 @@ export const CONTRACT_LINE =
14
14
  // The frontmatter is routing metadata — globs, cites, owns — consumed by the
15
15
  // router before dispatch. Sending it to the model costs tokens on every call and
16
16
  // tells it nothing it needs, so the body is what gets inlined.
17
+ function formatSpans(spans) {
18
+ return spans
19
+ .map(([start, end]) => start === end ? `${start}` : `${start}-${end}`)
20
+ .join(', ');
21
+ }
22
+
17
23
  export function stripFrontmatter(text) {
18
24
  return String(text ?? '').replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim();
19
25
  }
@@ -26,6 +32,7 @@ export function buildLensPrompt(lens, files, options = {}) {
26
32
  definitionPath = null,
27
33
  definition = null,
28
34
  mixedCorpus = false,
35
+ rangesByFile = null,
29
36
  extra = null
30
37
  } = options;
31
38
 
@@ -68,9 +75,29 @@ export function buildLensPrompt(lens, files, options = {}) {
68
75
  'reporting them here duplicates that lens and weakens the panel.');
69
76
  }
70
77
 
78
+ const ranges = rangesByFile ?? {};
79
+ const hasRanges = files.some(f => (ranges[f] ?? []).length > 0);
71
80
  parts.push(
72
81
  `Audit exactly these ${files.length} file(s), reading each one completely ` +
73
- 'before reporting on it:\n' + files.map(f => ` - ${f}`).join('\n'));
82
+ 'before reporting on it:\n' +
83
+ files.map(f => {
84
+ const spans = ranges[f] ?? [];
85
+ return spans.length
86
+ ? ` - ${f} (changed lines: ${formatSpans(spans)})`
87
+ : ` - ${f}`;
88
+ }).join('\n'));
89
+
90
+ if (hasRanges) {
91
+ // Reporting only inside the diff would miss the common case: a change that
92
+ // breaks something it did not touch. The changed lines are the priority, not
93
+ // the boundary.
94
+ parts.push(
95
+ 'This is a review of a change. The changed lines are listed above — read ' +
96
+ 'each file completely for context, and concentrate on what the change ' +
97
+ 'introduces or breaks. A defect elsewhere in the file is still worth ' +
98
+ 'reporting when the change causes it or depends on it; a pre-existing ' +
99
+ 'defect the change does not touch is not what this review is for.');
100
+ }
74
101
 
75
102
  if (mixedCorpus) {
76
103
  parts.push(
@@ -95,3 +122,62 @@ export function buildLensPrompt(lens, files, options = {}) {
95
122
 
96
123
  return parts.join('\n\n');
97
124
  }
125
+
126
+ // The verification pass. `foreman.md` has always called for refuting every
127
+ // BLOCK by default, on the grounds that a panel which cries wolf stops being
128
+ // read. The skeptic is given the file rather than the finding's own account of
129
+ // it, and told to default to refuted when the evidence is not there — a verifier
130
+ // that accepts a plausible story adds cost and no signal.
131
+ export function buildRefutePrompt(finding, options = {}) {
132
+ const { extra = null } = options;
133
+ if (!finding?.file) {
134
+ throw new Error('buildRefutePrompt requires a finding with a file');
135
+ }
136
+ const parts = [
137
+ 'You are verifying one claimed defect. Your job is to REFUTE it.',
138
+ `Claim: ${finding.file}:${finding.line} — ${finding.issue}` +
139
+ (finding.fix ? `\nProposed fix: ${finding.fix}` : ''),
140
+ `Read \`${finding.file}\` yourself. Do not take the claim's description of ` +
141
+ 'the code as accurate — check it. Then decide:\n\n' +
142
+ ' - Is the code actually as the claim describes?\n' +
143
+ ' - Can you name a concrete input, state, or sequence that triggers the ' +
144
+ 'defect?\n' +
145
+ ' - Does something already in the code prevent it — a guard, a validation, ' +
146
+ 'a caller contract, a type?',
147
+ 'Default to refuted. If you cannot demonstrate the defect is real, it is ' +
148
+ 'refuted. A finding that survives only because nobody could disprove it is ' +
149
+ 'the kind this pass exists to remove.'
150
+ ];
151
+ if (extra) {
152
+ parts.push(extra);
153
+ }
154
+ parts.push(
155
+ 'Reply with exactly one line:\n\n' +
156
+ ' REFUTED — why the claim does not hold\n' +
157
+ 'or\n' +
158
+ ' CONFIRMED — the concrete trigger you verified\n\n' +
159
+ 'No preamble, no other text.');
160
+ return parts.join('\n\n');
161
+ }
162
+
163
+ const REFUTED = /^\s*REFUTED\b/i;
164
+ const CONFIRMED = /^\s*CONFIRMED\b/i;
165
+
166
+ // An unparseable verdict is treated as refuted, matching the default above: a
167
+ // verifier that produced nothing usable has not established the finding.
168
+ export function parseVerdict(text) {
169
+ const line = String(text ?? '').trim().split('\n')
170
+ .find(l => REFUTED.test(l) || CONFIRMED.test(l)) ?? '';
171
+ if (CONFIRMED.test(line)) {
172
+ return {
173
+ refuted: false,
174
+ reason: line.replace(CONFIRMED, '').replace(/^\s*[—-]\s*/, '').trim() || null
175
+ };
176
+ }
177
+ return {
178
+ refuted: true,
179
+ reason: line
180
+ ? line.replace(REFUTED, '').replace(/^\s*[—-]\s*/, '').trim() || null
181
+ : 'verifier produced no usable verdict'
182
+ };
183
+ }
package/lib/run.mjs CHANGED
@@ -13,12 +13,42 @@
13
13
  // mean that lens did not produce a report — recorded as incomplete, never
14
14
  // silently treated as "found nothing".
15
15
 
16
- import { buildLensPrompt } from './prompt.mjs';
16
+ import { buildLensPrompt, buildRefutePrompt, parseVerdict } from './prompt.mjs';
17
17
  import { applyOverrides, routeRoster } from './lenses.mjs';
18
18
  import { parseLensOutput } from './parse.mjs';
19
19
 
20
20
  export const DEFAULT_CONCURRENCY = 4;
21
21
 
22
+ // Which command runs a given lens. A conventions lens checking copyright years
23
+ // does not need what a security lens needs, and cost is what decides whether a
24
+ // team leaves this switched on. Precedence: the lens's own `exec`, then a
25
+ // per-lens entry in the config's exec map, then the single default.
26
+ export function resolveExec(lens, exec) {
27
+ if (lens?.exec) {
28
+ return lens.exec;
29
+ }
30
+ if (exec && typeof exec === 'object') {
31
+ return exec[lens?.name] ?? exec.default ?? null;
32
+ }
33
+ return exec ?? null;
34
+ }
35
+
36
+ // crosscheck cannot see tokens or money — `--exec` is an arbitrary command — so
37
+ // the only unit it can honestly cap is the number of dispatches. Pretending to
38
+ // budget dollars would be a number made up from nothing.
39
+ export function planBudget(jobs, maxDispatches) {
40
+ if (maxDispatches == null || maxDispatches >= (jobs ?? []).length) {
41
+ return { run: jobs ?? [], dropped: [] };
42
+ }
43
+ if (maxDispatches <= 0) {
44
+ return { run: [], dropped: jobs ?? [] };
45
+ }
46
+ return {
47
+ run: jobs.slice(0, maxDispatches),
48
+ dropped: jobs.slice(maxDispatches)
49
+ };
50
+ }
51
+
22
52
  // lenses: [{name, when, owns, 'not-owns', definition?, definitionPath?}]
23
53
  // Returns {roster, skipped} — skipped always carries a reason, because a lens
24
54
  // dropped without one reads as coverage that never happened.
@@ -56,6 +86,49 @@ export function promptsFor(roster, options = {}) {
56
86
  }));
57
87
  }
58
88
 
89
+ // Refute each finding independently, bounded by the same concurrency cap. Returns
90
+ // verdicts keyed by finding key, plus any verifier that failed — a verifier that
91
+ // did not run must not be read as agreement, so its finding is left standing and
92
+ // the failure is reported.
93
+ export async function verifyFindings({
94
+ findings,
95
+ exec,
96
+ concurrency = DEFAULT_CONCURRENCY,
97
+ onVerdict = null
98
+ } = {}) {
99
+ if (typeof exec !== 'function') {
100
+ throw new Error('verifyFindings requires an exec function');
101
+ }
102
+ const targets = findings ?? [];
103
+ const verdicts = {};
104
+ const failures = [];
105
+ await mapLimit(targets, concurrency, async finding => {
106
+ let result;
107
+ try {
108
+ result = await exec({
109
+ prompt: buildRefutePrompt(finding),
110
+ lens: `verify:${finding.lenses?.[0] ?? 'finding'}`,
111
+ files: [finding.file]
112
+ });
113
+ } catch (error) {
114
+ failures.push({
115
+ finding: finding.key, reason: error?.message ?? String(error)
116
+ });
117
+ return;
118
+ }
119
+ if ((result?.code ?? 0) !== 0) {
120
+ failures.push({
121
+ finding: finding.key, reason: `verifier exited ${result?.code}`
122
+ });
123
+ return;
124
+ }
125
+ const verdict = parseVerdict(result?.stdout);
126
+ verdicts[finding.key] = verdict;
127
+ onVerdict?.(finding, verdict);
128
+ });
129
+ return { verdicts, failures };
130
+ }
131
+
59
132
  // Bounded-concurrency map that preserves input order. Kept local rather than
60
133
  // pulling in a dependency for nine lines.
61
134
  async function mapLimit(items, limit, worker) {
@@ -84,17 +157,37 @@ export async function runPanel({
84
157
  exec,
85
158
  concurrency = DEFAULT_CONCURRENCY,
86
159
  promptOptions = {},
160
+ cache = null,
161
+ cacheKeyFor = null,
162
+ maxDispatches = null,
87
163
  onLensStart = null,
88
- onLensDone = null
164
+ onLensDone = null,
165
+ onCacheHit = null
89
166
  } = {}) {
90
167
  if (typeof exec !== 'function') {
91
168
  throw new Error('runPanel requires an exec function');
92
169
  }
93
- const jobs = promptsFor(roster ?? [], promptOptions);
170
+ const allJobs = promptsFor(roster ?? [], promptOptions);
94
171
  const failures = [];
95
172
 
173
+ // Truncation is disclosed, never silent: a run that quietly stopped early
174
+ // looks exactly like a run that found nothing.
175
+ const { run: jobs, dropped } = planBudget(allJobs, maxDispatches);
176
+
96
177
  const reports = await mapLimit(jobs, concurrency, async job => {
97
178
  onLensStart?.(job.lens);
179
+ const key = cache && cacheKeyFor ? cacheKeyFor(job) : null;
180
+ if (key) {
181
+ const cached = cache.get(key, job.lens);
182
+ if (cached != null) {
183
+ const { findings, unparsed } = parseLensOutput(cached);
184
+ onCacheHit?.(job.lens);
185
+ onLensDone?.(job.lens, {
186
+ ok: true, findings: findings.length, cached: true
187
+ });
188
+ return { lens: job.lens, findings, unparsed, output: cached };
189
+ }
190
+ }
98
191
  let result;
99
192
  try {
100
193
  result = await exec({ prompt: job.prompt, lens: job.lens, files: job.files });
@@ -124,11 +217,20 @@ export async function runPanel({
124
217
  return { lens: job.lens, findings: null, unparsed: [], output: null };
125
218
  }
126
219
  const { findings, unparsed } = parseLensOutput(stdout);
220
+ if (key) {
221
+ cache.set(key, job.lens, stdout);
222
+ }
127
223
  onLensDone?.(job.lens, { ok: true, findings: findings.length });
128
224
  // `output` is the verbatim lens text, kept so `--out` can save a run and it
129
225
  // can be rescored later without paying the model again.
130
226
  return { lens: job.lens, findings, unparsed, output: stdout };
131
227
  });
132
228
 
133
- return { reports, skipped, failures };
229
+ return {
230
+ reports,
231
+ skipped,
232
+ failures,
233
+ dropped: dropped.map(j => ({ lens: j.lens, files: j.files.length })),
234
+ cacheStats: cache?.stats ?? null
235
+ };
134
236
  }