@oss-autopilot/core 3.14.0 → 3.14.2

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.
@@ -182,6 +182,28 @@ export async function fetchDashboardData(token) {
182
182
  // Get watermarks for incremental PR fetch
183
183
  const watermark = stateManager.getMergedPRWatermark();
184
184
  const closedWatermark = stateManager.getClosedPRWatermark();
185
+ // Self-heal a ledger stranded behind a recent watermark (#1504). The
186
+ // incremental fetch only asks for merges newer than the newest stored one,
187
+ // so once the recent-merge feed (daily's recentlyMergedPRs) seeds the ledger
188
+ // before any full historical fetch ran, the older history can never be
189
+ // recovered. When the stored detail count is materially below the known
190
+ // all-time merged count, drop the watermark for a one-shot full backfill
191
+ // (bounded by the existing pagination cap); once the ledger catches up the
192
+ // watermark resumes incremental fetching. Closed PRs are not seeded ahead of
193
+ // their backfill in practice, so only merged needs the guard.
194
+ const storedMergedCount = stateManager.getMergedPRs().length;
195
+ const knownMergedAllTime = stateManager.getState().lastDigest?.summary?.totalMergedAllTime ?? 0;
196
+ // A full backfill can only ever return up to fetchMergedPRsSince's pagination
197
+ // cap (MAX_PAGINATION_PAGES * 100 = 300). Stop forcing one once the ledger has
198
+ // reached that ceiling, so a contributor whose star-filtered all-time count
199
+ // legitimately exceeds 300 doesn't trigger a full refetch on every poll.
200
+ const MERGED_BACKFILL_CEILING = 300;
201
+ const mergedNeedsBackfill = storedMergedCount < knownMergedAllTime && storedMergedCount < MERGED_BACKFILL_CEILING;
202
+ const mergedFetchSince = mergedNeedsBackfill ? undefined : watermark;
203
+ if (mergedNeedsBackfill) {
204
+ warn(MODULE, `Merged-PR ledger has ${storedMergedCount} of ~${knownMergedAllTime} known merges; ` +
205
+ 'running a full backfill (watermark dropped) to recover history (#1504).');
206
+ }
185
207
  // Track which non-critical sub-fetches degraded to fallbacks so the SPA
186
208
  // can surface a "partial data" banner instead of silently showing zeros.
187
209
  // Rate-limit / auth errors still rethrow via isRateLimitOrAuthError —
@@ -223,7 +245,7 @@ export async function fetchDashboardData(token) {
223
245
  failures: [{ issueUrl: 'N/A', error: `Issue conversation fetch failed: ${msg}` }],
224
246
  };
225
247
  }),
226
- fetchMergedPRsSince(octokit, config, watermark).catch(trackingCatch('fetch merged PRs for storage', [])),
248
+ fetchMergedPRsSince(octokit, config, mergedFetchSince).catch(trackingCatch('fetch merged PRs for storage', [])),
227
249
  fetchClosedPRsSince(octokit, config, closedWatermark).catch(trackingCatch('fetch closed PRs for storage', [])),
228
250
  ]);
229
251
  const commentedIssues = fetchedIssues.issues;
@@ -258,17 +258,6 @@ export async function startDashboardServer(options) {
258
258
  // signal) lives inside reloadState too — do NOT add a second
259
259
  // maybeRecoverGist call here or recovery would double-run per poll.
260
260
  const stateChanged = await reloadState();
261
- // Gist-mode staleness (#1446 item 2): refreshFromGist() returns
262
- // false on BOTH "no change" and "fetch failed", so the boolean can't
263
- // signal failure. getStateStaleness() is the API built for exactly
264
- // this — StateManager sets the marker when in-memory state diverged
265
- // from the canonical Gist (refresh failure, invalid payload,
266
- // degraded bootstrap) and clears it on a successful pull.
267
- const staleness = gistSync.stateManager.getStateStaleness();
268
- if (staleness !== null) {
269
- res.setHeader('X-Dashboard-Stale', '1');
270
- res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`state-stale: ${staleness.reason}`));
271
- }
272
261
  // Also rebuild when the vetted issue list file was edited outside this server (#924)
273
262
  const currentIssueListMtimeMs = getIssueListMtimeMs();
274
263
  const issueListChanged = currentIssueListMtimeMs !== cache.issueListMtimeMs;
@@ -283,14 +272,10 @@ export async function startDashboardServer(options) {
283
272
  res.setHeader('X-Dashboard-Stale', '1');
284
273
  }
285
274
  }
286
- // Surface staleness from a failed background refresh too (#1205) so
287
- // token expiry / GitHub outage produces a client-visible signal
288
- // rather than silent stale data. Only set the header when a failure
289
- // is recorded — successful refreshes clear it.
290
- if (cache.lastBackgroundRefreshError !== null) {
291
- res.setHeader('X-Dashboard-Stale', '1');
292
- res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`background-refresh-failed: ${cache.lastBackgroundRefreshError}`));
293
- }
275
+ // Gist-divergence + background-refresh staleness (#1446 item 2, #1205)
276
+ // via the shared helper so this GET and the POST mutators emit the
277
+ // same headers (#1487). The rebuild-failure flag above is GET-only.
278
+ applyStalenessHeaders(res);
294
279
  sendJson(res, 200, cache.jsonData);
295
280
  return;
296
281
  }
@@ -398,6 +383,36 @@ export async function startDashboardServer(options) {
398
383
  changed = true;
399
384
  return changed;
400
385
  }
386
+ /**
387
+ * Emit the X-Dashboard-Stale / X-Dashboard-Stale-Reason headers from the
388
+ * two server-lived staleness sources so EVERY response — GET /api/data and
389
+ * the POST mutators — reflects the same truth (#1487).
390
+ *
391
+ * Before this the setters were GET-only, but the SPA's applyResult clears
392
+ * its `stale` flag on ANY successfully-applied response. A dashboard whose
393
+ * gist pulls keep failing while POST /api/refresh and /api/action succeed
394
+ * would flip back to "healthy" until the next GET (mount or CSRF re-prime).
395
+ * Carrying the headers on the POST paths keeps the flag honest.
396
+ *
397
+ * Reads the same two probes the GET path used inline: the StateManager's
398
+ * gist-divergence marker (getStateStaleness, #1446 item 2) — set on a
399
+ * refresh failure / invalid payload / degraded bootstrap and cleared on a
400
+ * successful pull — and the cache's last background-refresh error (#1205).
401
+ * When both are set the background-refresh reason wins, matching the prior
402
+ * GET ordering. The GET path's third, transient source — an inline rebuild
403
+ * failure (#994) — is specific to that handler and stays inline there.
404
+ */
405
+ function applyStalenessHeaders(res) {
406
+ const staleness = gistSync.stateManager.getStateStaleness();
407
+ if (staleness !== null) {
408
+ res.setHeader('X-Dashboard-Stale', '1');
409
+ res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`state-stale: ${staleness.reason}`));
410
+ }
411
+ if (cache.lastBackgroundRefreshError !== null) {
412
+ res.setHeader('X-Dashboard-Stale', '1');
413
+ res.setHeader('X-Dashboard-Stale-Reason', sanitizeHeaderValue(`background-refresh-failed: ${cache.lastBackgroundRefreshError}`));
414
+ }
415
+ }
401
416
  async function handleAction(req, res) {
402
417
  let body;
403
418
  try {
@@ -514,6 +529,10 @@ export async function startDashboardServer(options) {
514
529
  // SPA banner survives user interactions until the next successful
515
530
  // refresh clears it.
516
531
  cache.rebuild(gistSync.stateManager.getState());
532
+ // Carry the same staleness signal a GET would (#1487): a mutation that
533
+ // succeeds locally while the gist stays unreachable must not let the SPA
534
+ // clear a previously-stale flag.
535
+ applyStalenessHeaders(res);
517
536
  sendJson(res, 200, cache.jsonData);
518
537
  }
519
538
  // ── POST /api/refresh handler ────────────────────────────────────────────
@@ -535,6 +554,14 @@ export async function startDashboardServer(options) {
535
554
  const result = await fetchDashboardData(currentToken);
536
555
  cache.adoptFetchResult(result);
537
556
  cache.rebuild(gistSync.stateManager.getState(), result.allMergedPRs, result.allClosedPRs);
557
+ // This manual refresh just fetched GitHub successfully, so a prior
558
+ // background-refresh failure is no longer current (#1487) — clear it as
559
+ // the background success path does, then emit the staleness headers. A
560
+ // gist-divergence marker that survived a failed pull inside reloadState
561
+ // still surfaces via getStateStaleness, so the SPA stays honest even
562
+ // when the GitHub fetch succeeds but the gist remains unreachable.
563
+ cache.lastBackgroundRefreshError = null;
564
+ applyStalenessHeaders(res);
538
565
  sendJson(res, 200, cache.jsonData);
539
566
  }
540
567
  catch (error) {
@@ -3,7 +3,11 @@
3
3
  *
4
4
  * The file format is one entry per line:
5
5
  * 2026-04-15 https://github.com/owner/repo/issues/123
6
- * Lines starting with `#` and blank lines are ignored.
6
+ * Lines starting with `#` and blank lines are ignored. A trailing inline
7
+ * comment is also allowed and ignored:
8
+ * 2026-04-15 https://github.com/owner/repo/issues/123 # reason for skipping
9
+ * The comment delimiter is whitespace followed by `#`, so a `#fragment` inside
10
+ * the URL (no preceding whitespace) is preserved, not treated as a comment.
7
11
  *
8
12
  * Produces SkippedIssue entries that plug directly into oss-scout's ScoutState
9
13
  * so the search engine filters already-skipped URLs out of results.
@@ -3,7 +3,11 @@
3
3
  *
4
4
  * The file format is one entry per line:
5
5
  * 2026-04-15 https://github.com/owner/repo/issues/123
6
- * Lines starting with `#` and blank lines are ignored.
6
+ * Lines starting with `#` and blank lines are ignored. A trailing inline
7
+ * comment is also allowed and ignored:
8
+ * 2026-04-15 https://github.com/owner/repo/issues/123 # reason for skipping
9
+ * The comment delimiter is whitespace followed by `#`, so a `#fragment` inside
10
+ * the URL (no preceding whitespace) is preserved, not treated as a comment.
7
11
  *
8
12
  * Produces SkippedIssue entries that plug directly into oss-scout's ScoutState
9
13
  * so the search engine filters already-skipped URLs out of results.
@@ -26,8 +30,10 @@ export function parseSkippedIssuesContent(content) {
26
30
  const line = lines[i].trim();
27
31
  if (line === '' || line.startsWith('#'))
28
32
  continue;
29
- // Split on first whitespace run: "YYYY-MM-DD <url>"
30
- const match = line.match(/^(\S+)\s+(\S+)\s*$/);
33
+ // Parse "YYYY-MM-DD <url>" with an optional trailing "# comment". The
34
+ // comment delimiter is whitespace followed by `#`, so a `#fragment` inside
35
+ // the URL (no preceding whitespace) stays part of the URL token.
36
+ const match = line.match(/^(\S+)\s+(\S+)(?:\s+#.*)?$/);
31
37
  if (!match) {
32
38
  warn('skip-file-parser', `Line ${lineNumber}: malformed (expected "<date> <url>"): ${line}`);
33
39
  continue;
@@ -3,6 +3,7 @@
3
3
  * Re-vets all available issues in a curated issue list file via @oss-scout/core.
4
4
  */
5
5
  import { type VetListOutput, type VetOutput, type VetListItemStatus } from '../formatters/json.js';
6
+ import { type IssueAvailabilityVerdict } from '../core/index.js';
6
7
  interface VetListOptions {
7
8
  issueListPath?: string;
8
9
  concurrency?: number;
@@ -35,12 +36,11 @@ export declare function extractSkipReason(candidate: unknown): ScoutSkipReason |
35
36
  */
36
37
  export declare function classifyListStatus(vetResult: VetOutput, skipReason?: ScoutSkipReason): VetListItemStatus;
37
38
  /**
38
- * Re-vet all available issues in a curated issue list.
39
- * Reads the list file, extracts available (non-done) issues,
40
- * and vets each in parallel with concurrency control.
41
- *
42
- * @param options - Vet-list options
43
- * @returns Consolidated vetting results with list status for each issue
39
+ * Map a deterministic verify-issue verdict onto the vet-list status taxonomy
40
+ * (#1494). This is the authoritative path: the GraphQL verdict knows the
41
+ * closing-vs-mention distinction (#1353) that scout's substring heuristic
42
+ * cannot, so `at_risk` (mention only) no longer collapses into `claimed`.
44
43
  */
44
+ export declare function listStatusFromVerdict(verdict: IssueAvailabilityVerdict): VetListItemStatus;
45
45
  export declare function runVetList(options?: VetListOptions): Promise<VetListOutput>;
46
46
  export {};
@@ -7,7 +7,8 @@ import { adaptScoutLinkedPR, buildCandidateLinkedPR, createAutopilotScout } from
7
7
  import { runParseList, pruneIssueList } from './parse-list.js';
8
8
  import { detectIssueList } from './startup.js';
9
9
  import { computeSuccessGrade, gradeFromCandidate } from '../core/issue-grading.js';
10
- import { getStateManager, classifyLinkedPR } from '../core/index.js';
10
+ import { ValidationError } from '../core/errors.js';
11
+ import { getStateManager, classifyLinkedPR, getOctokit, requireGitHubToken, parseGitHubUrl, verifyIssuesBatch, } from '../core/index.js';
11
12
  const UNKNOWN_GRADE = computeSuccessGrade({ avgResponseDays: null, mergeRate: null, daysSinceLastCommit: null });
12
13
  const KNOWN_SKIP_REASONS = new Set([
13
14
  'issue_closed',
@@ -93,13 +94,64 @@ export function classifyListStatus(vetResult, skipReason) {
93
94
  return 'still_available';
94
95
  }
95
96
  /**
96
- * Re-vet all available issues in a curated issue list.
97
- * Reads the list file, extracts available (non-done) issues,
98
- * and vets each in parallel with concurrency control.
99
- *
100
- * @param options - Vet-list options
101
- * @returns Consolidated vetting results with list status for each issue
97
+ * Map a deterministic verify-issue verdict onto the vet-list status taxonomy
98
+ * (#1494). This is the authoritative path: the GraphQL verdict knows the
99
+ * closing-vs-mention distinction (#1353) that scout's substring heuristic
100
+ * cannot, so `at_risk` (mention only) no longer collapses into `claimed`.
101
+ */
102
+ export function listStatusFromVerdict(verdict) {
103
+ switch (verdict) {
104
+ case 'closed': {
105
+ return 'closed';
106
+ }
107
+ case 'own-open-pr': {
108
+ return 'own_open_pr';
109
+ }
110
+ case 'taken': {
111
+ return 'claimed';
112
+ }
113
+ case 'at-risk': {
114
+ return 'at_risk';
115
+ }
116
+ case 'available': {
117
+ return 'still_available';
118
+ }
119
+ }
120
+ }
121
+ /**
122
+ * Verdicts that conclusively rule an issue out as a new opportunity. For these
123
+ * we skip scout's grade/health vet entirely — there is nothing to grade — and
124
+ * build the result row straight from the verification.
102
125
  */
126
+ const RULED_OUT_VERDICTS = new Set(['closed', 'taken', 'own-open-pr']);
127
+ /** The per-row `verification` sub-object: the authoritative facts behind the
128
+ * status, projected from the full {@link IssueVerification}. */
129
+ function toVerificationSummary(v) {
130
+ return {
131
+ verdict: v.verdict,
132
+ verdictReason: v.verdictReason,
133
+ state: v.state,
134
+ stateReason: v.stateReason,
135
+ assignees: v.assignees,
136
+ linkedPRs: v.linkedPRs,
137
+ };
138
+ }
139
+ /** Minimal VetOutput for a ruled-out issue (no scout vet was run). The
140
+ * verification carries the real reason; scout-derived fields are left empty. */
141
+ function ruledOutVetOutput(v) {
142
+ return {
143
+ issue: { repo: `${v.owner}/${v.repo}`, number: v.number, title: v.title, url: v.url, labels: [] },
144
+ recommendation: 'skip',
145
+ reasonsToApprove: [],
146
+ reasonsToSkip: [v.verdictReason],
147
+ projectHealth: {},
148
+ vettingResult: {},
149
+ grade: UNKNOWN_GRADE,
150
+ };
151
+ }
152
+ function toMessage(error) {
153
+ return error instanceof Error ? error.message : String(error);
154
+ }
103
155
  export async function runVetList(options = {}) {
104
156
  const concurrency = options.concurrency ?? 5;
105
157
  // 1. Find and parse the issue list
@@ -115,75 +167,146 @@ export async function runVetList(options = {}) {
115
167
  if (parsed.available.length === 0) {
116
168
  return {
117
169
  results: [],
118
- summary: { total: 0, stillAvailable: 0, claimed: 0, closed: 0, hasPR: 0, hasStalledPR: 0, errors: 0 },
170
+ summary: {
171
+ total: 0,
172
+ stillAvailable: 0,
173
+ claimed: 0,
174
+ closed: 0,
175
+ hasPR: 0,
176
+ hasStalledPR: 0,
177
+ atRisk: 0,
178
+ ownOpenPr: 0,
179
+ errors: 0,
180
+ },
119
181
  };
120
182
  }
121
- // 2. Vet each available issue in parallel with concurrency limit
122
- const scout = await createAutopilotScout();
123
- const results = [];
124
- // Simple concurrency limiter
125
183
  const items = parsed.available;
126
- let index = 0;
127
- async function processNext() {
128
- while (index < items.length) {
129
- const item = items[index++];
184
+ // 2. Verify FIRST (#1494): one deterministic GraphQL check per entry, in a
185
+ // bounded batch. The verdict is closing-vs-mention aware, so it carries
186
+ // the authoritative status and ruling issues out here means we skip
187
+ // scout's search-driven vet (a net REDUCTION in /search/issues calls).
188
+ const octokit = getOctokit(requireGitHubToken());
189
+ const verifyTargets = [];
190
+ items.forEach((item, i) => {
191
+ const ghUrl = parseGitHubUrl(item.url);
192
+ if (ghUrl && ghUrl.type === 'issues') {
193
+ verifyTargets.push({ index: i, params: { owner: ghUrl.owner, repo: ghUrl.repo, number: ghUrl.number } });
194
+ }
195
+ });
196
+ const batch = await verifyIssuesBatch(octokit, verifyTargets.map((t) => t.params), { concurrency });
197
+ const verificationByIndex = new Map();
198
+ verifyTargets.forEach((t, k) => verificationByIndex.set(t.index, batch[k]));
199
+ // 3. Vet only the issues still in play; build each row.
200
+ const scout = await createAutopilotScout();
201
+ /** Run scout's grade/health vet for an issue (the search-driven path). */
202
+ async function runScoutVet(item) {
203
+ const candidate = await scout.vetIssue(item.url);
204
+ const grade = gradeFromCandidate({
205
+ repo: candidate.issue.repo,
206
+ projectHealth: candidate.projectHealth,
207
+ getRepoScore: (repo) => getStateManager().getRepoScore(repo),
208
+ });
209
+ const userLogin = getStateManager().getState().config.githubUsername;
210
+ const linkedPRClassification = classifyLinkedPR({
211
+ linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
212
+ userLogin,
213
+ });
214
+ const linkedPR = buildCandidateLinkedPR(candidate.vettingResult.linkedPR);
215
+ const vetResult = {
216
+ issue: {
217
+ repo: candidate.issue.repo,
218
+ number: candidate.issue.number,
219
+ title: candidate.issue.title,
220
+ url: candidate.issue.url,
221
+ labels: candidate.issue.labels,
222
+ },
223
+ recommendation: candidate.recommendation,
224
+ reasonsToApprove: candidate.reasonsToApprove,
225
+ reasonsToSkip: candidate.reasonsToSkip,
226
+ projectHealth: candidate.projectHealth,
227
+ vettingResult: candidate.vettingResult,
228
+ antiLLMPolicy: candidate.antiLLMPolicy,
229
+ linkedPRClassification,
230
+ ...(linkedPR ? { linkedPR } : {}),
231
+ slmTriage: candidate.slmTriage ?? null,
232
+ grade,
233
+ };
234
+ return { vetResult, skipReason: extractSkipReason(candidate) };
235
+ }
236
+ function errorRow(item, error) {
237
+ const message = toMessage(error);
238
+ return {
239
+ issue: { repo: item.repo, number: item.number, title: item.title, url: item.url, labels: [] },
240
+ recommendation: 'skip',
241
+ reasonsToApprove: [],
242
+ reasonsToSkip: [`Error: ${message}`],
243
+ projectHealth: {},
244
+ vettingResult: {},
245
+ grade: UNKNOWN_GRADE,
246
+ listStatus: 'error',
247
+ errorMessage: message,
248
+ };
249
+ }
250
+ async function processItem(item, verification) {
251
+ // No authoritative verdict for this row: either verify errored, or the URL
252
+ // was never enrolled (parse-list also accepts PR URLs, which `verify-issue`
253
+ // can't classify). Either way `listStatus` falls back to scout's heuristic,
254
+ // so we ALWAYS tag the row with `verifyError` — the explicit signal that the
255
+ // status is not authoritative and must be re-verified before acting.
256
+ if (!verification || verification.verification === undefined) {
257
+ const verifyError = verification?.error;
258
+ // A ValidationError means the issue definitively doesn't exist (deleted,
259
+ // private, or the URL points at a PR). Don't re-grade it via scout — that
260
+ // could mislabel a dead issue as available. Report it as an error row.
261
+ if (verifyError instanceof ValidationError) {
262
+ return { ...errorRow(item, verifyError), verifyError: toMessage(verifyError) };
263
+ }
264
+ // Transient verify failure (rate limit, network, truncated timeline), or a
265
+ // non-issue URL that was never sent to verify. Fall back to scout's
266
+ // heuristic, tagged so the row is re-verified later.
267
+ const verifyErrorTag = {
268
+ verifyError: verifyError === undefined
269
+ ? `not verified: ${item.url} is not a verifiable issue URL`
270
+ : toMessage(verifyError),
271
+ };
130
272
  try {
131
- const candidate = await scout.vetIssue(item.url);
132
- const grade = gradeFromCandidate({
133
- repo: candidate.issue.repo,
134
- projectHealth: candidate.projectHealth,
135
- getRepoScore: (repo) => getStateManager().getRepoScore(repo),
136
- });
137
- const userLogin = getStateManager().getState().config.githubUsername;
138
- const linkedPRClassification = classifyLinkedPR({
139
- linkedPR: adaptScoutLinkedPR(candidate.vettingResult.linkedPR),
140
- userLogin,
141
- });
142
- const linkedPR = buildCandidateLinkedPR(candidate.vettingResult.linkedPR);
143
- const vetResult = {
144
- issue: {
145
- repo: candidate.issue.repo,
146
- number: candidate.issue.number,
147
- title: candidate.issue.title,
148
- url: candidate.issue.url,
149
- labels: candidate.issue.labels,
150
- },
151
- recommendation: candidate.recommendation,
152
- reasonsToApprove: candidate.reasonsToApprove,
153
- reasonsToSkip: candidate.reasonsToSkip,
154
- projectHealth: candidate.projectHealth,
155
- vettingResult: candidate.vettingResult,
156
- antiLLMPolicy: candidate.antiLLMPolicy,
157
- linkedPRClassification,
158
- ...(linkedPR ? { linkedPR } : {}),
159
- slmTriage: candidate.slmTriage ?? null,
160
- grade,
161
- };
162
- results.push({
163
- ...vetResult,
164
- listStatus: classifyListStatus(vetResult, extractSkipReason(candidate)),
165
- });
273
+ const { vetResult, skipReason } = await runScoutVet(item);
274
+ return { ...vetResult, listStatus: classifyListStatus(vetResult, skipReason), ...verifyErrorTag };
166
275
  }
167
276
  catch (error) {
168
- // Per-issue errors don't fail the batch
169
- results.push({
170
- issue: { repo: item.repo, number: item.number, title: item.title, url: item.url, labels: [] },
171
- recommendation: 'skip',
172
- reasonsToApprove: [],
173
- reasonsToSkip: [`Error: ${error instanceof Error ? error.message : String(error)}`],
174
- projectHealth: {},
175
- vettingResult: {},
176
- grade: UNKNOWN_GRADE,
177
- listStatus: 'error',
178
- errorMessage: error instanceof Error ? error.message : String(error),
179
- });
277
+ return { ...errorRow(item, error), ...verifyErrorTag };
180
278
  }
181
279
  }
280
+ const v = verification.verification;
281
+ const summary = toVerificationSummary(v);
282
+ const listStatus = listStatusFromVerdict(v.verdict);
283
+ // Conclusively ruled out — skip the scout vet entirely.
284
+ if (RULED_OUT_VERDICTS.has(v.verdict)) {
285
+ return { ...ruledOutVetOutput(v), listStatus, verification: summary };
286
+ }
287
+ // available / at-risk: still worth grading. The verdict stays authoritative
288
+ // for listStatus; scout only supplies grade/health/recommendation.
289
+ try {
290
+ const { vetResult } = await runScoutVet(item);
291
+ return { ...vetResult, listStatus, verification: summary };
292
+ }
293
+ catch (error) {
294
+ // Verify succeeded, so keep its verdict facts even though grading failed.
295
+ return { ...errorRow(item, error), verification: summary };
296
+ }
297
+ }
298
+ const results = new Array(items.length);
299
+ let index = 0;
300
+ async function processNext() {
301
+ while (index < items.length) {
302
+ const i = index++;
303
+ results[i] = await processItem(items[i], verificationByIndex.get(i));
304
+ }
182
305
  }
183
- // Start `concurrency` workers
306
+ // Start `concurrency` workers (bounds the scout-vet fan-out).
184
307
  const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => processNext());
185
308
  await Promise.all(workers);
186
- // 3. Compute summary
309
+ // 4. Compute summary
187
310
  const summary = {
188
311
  total: results.length,
189
312
  stillAvailable: results.filter((r) => r.listStatus === 'still_available').length,
@@ -191,6 +314,8 @@ export async function runVetList(options = {}) {
191
314
  closed: results.filter((r) => r.listStatus === 'closed').length,
192
315
  hasPR: results.filter((r) => r.listStatus === 'has_pr').length,
193
316
  hasStalledPR: results.filter((r) => r.listStatus === 'has_stalled_pr').length,
317
+ atRisk: results.filter((r) => r.listStatus === 'at_risk').length,
318
+ ownOpenPr: results.filter((r) => r.listStatus === 'own_open_pr').length,
194
319
  errors: results.filter((r) => r.listStatus === 'error').length,
195
320
  };
196
321
  // 4. Prune the file if requested — remove completed/skipped/low-score items
@@ -23,7 +23,7 @@ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsBy
23
23
  export { computeContributionStats, type ContributionStats, type ComputeStatsInput } from './stats.js';
24
24
  export { fetchPRTemplate, type PRTemplateResult } from './pr-template.js';
25
25
  export { classifyLinkedPR, isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, type LinkedPR, type LinkedPRClassification, type LinkedPRState, } from './linked-pr-classification.js';
26
- export { classifyIssueAvailability, fetchIssueVerification, type IssueAvailabilityVerdict, type IssueVerification, type LinkedPRLinkType, type VerifiedLinkedPR, type VerifyIssueParams, } from './issue-verification.js';
26
+ export { classifyIssueAvailability, fetchIssueVerification, verifyIssuesBatch, MAX_VERIFY_CONCURRENCY, type BatchVerificationResult, type IssueAvailabilityVerdict, type IssueVerification, type LinkedPRLinkType, type VerifiedLinkedPR, type VerifyIssueParams, } from './issue-verification.js';
27
27
  export { classifyAttentionBucket, summarizeAttentionBuckets, STUCK_CI_THRESHOLD_DAYS, DORMANT_FOLLOWUP_THRESHOLD_DAYS, type AttentionBucket, type AttentionInput, type AttentionSummary, } from './pr-attention.js';
28
28
  export { scanForAntiLLMPolicy, type AntiLLMCategory, type AntiLLMMatch, type AntiLLMScanResult, } from './anti-llm-policy.js';
29
29
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, type DetectedFormatter, type FormatterDetectionResult, type CIFormatterDiagnosis, type FormatterName, } from './formatter-detection.js';
@@ -24,7 +24,7 @@ export { CRITICAL_STATUSES, applyStatusOverrides, computeRepoSignals, groupPRsBy
24
24
  export { computeContributionStats } from './stats.js';
25
25
  export { fetchPRTemplate } from './pr-template.js';
26
26
  export { classifyLinkedPR, isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, } from './linked-pr-classification.js';
27
- export { classifyIssueAvailability, fetchIssueVerification, } from './issue-verification.js';
27
+ export { classifyIssueAvailability, fetchIssueVerification, verifyIssuesBatch, MAX_VERIFY_CONCURRENCY, } from './issue-verification.js';
28
28
  export { classifyAttentionBucket, summarizeAttentionBuckets, STUCK_CI_THRESHOLD_DAYS, DORMANT_FOLLOWUP_THRESHOLD_DAYS, } from './pr-attention.js';
29
29
  export { scanForAntiLLMPolicy, } from './anti-llm-policy.js';
30
30
  export { detectFormatters, diagnoseCIFormatterFailure, getPreferredFormatter, } from './formatter-detection.js';
@@ -89,3 +89,42 @@ export interface VerifyIssueParams {
89
89
  number: number;
90
90
  }
91
91
  export declare function fetchIssueVerification(octokit: Octokit, params: VerifyIssueParams): Promise<IssueVerification>;
92
+ /**
93
+ * Hard ceiling on concurrent in-flight verifications. Each issue runs its own
94
+ * GraphQL round-trip (point-heavy, with timeline pagination), so we never run
95
+ * more than this many at once regardless of what the caller requests. The
96
+ * single-issue path already leans on ThrottledOctokit's secondary-rate-limit
97
+ * backoff — this cap keeps us from spraying enough parallel queries to trip it.
98
+ */
99
+ export declare const MAX_VERIFY_CONCURRENCY = 5;
100
+ /**
101
+ * Per-item outcome of a batched verification run. Aligned by index with the
102
+ * input `items`. Error isolation: one item's failure lands as `{ error }` and
103
+ * never aborts the batch, so a single NOT_FOUND (or transient network error)
104
+ * does not poison every other entry — unlike an aliased mega-query would.
105
+ */
106
+ export interface BatchVerificationResult {
107
+ params: VerifyIssueParams;
108
+ /** Present when verification succeeded for this item. */
109
+ verification?: IssueVerification;
110
+ /** Present when `fetchIssueVerification` threw for this item. */
111
+ error?: unknown;
112
+ }
113
+ /**
114
+ * Verify many issues with bounded concurrent fan-out of the single-issue
115
+ * {@link fetchIssueVerification}.
116
+ *
117
+ * This is a deliberately thin worker pool, NOT one aliased GraphQL query:
118
+ * per-issue timeline cursors make aliasing unmanageable, and all-or-nothing
119
+ * failure (one bad issue poisoning the batch) is exactly what we want to
120
+ * avoid. No retry layer is added here — `octokit` is expected to be a
121
+ * ThrottledOctokit whose backoff already covers secondary rate limits.
122
+ *
123
+ * @param octokit - Throttled Octokit instance (see `getOctokit`).
124
+ * @param items - Issues to verify; results align with this order.
125
+ * @param options.concurrency - Desired parallelism; capped at
126
+ * {@link MAX_VERIFY_CONCURRENCY} and floored at 1.
127
+ */
128
+ export declare function verifyIssuesBatch(octokit: Octokit, items: readonly VerifyIssueParams[], options?: {
129
+ concurrency?: number;
130
+ }): Promise<BatchVerificationResult[]>;
@@ -268,3 +268,51 @@ export async function fetchIssueVerification(octokit, params) {
268
268
  userLogin,
269
269
  };
270
270
  }
271
+ // ── Batched verification ───────────────────────────────────────────────
272
+ /**
273
+ * Hard ceiling on concurrent in-flight verifications. Each issue runs its own
274
+ * GraphQL round-trip (point-heavy, with timeline pagination), so we never run
275
+ * more than this many at once regardless of what the caller requests. The
276
+ * single-issue path already leans on ThrottledOctokit's secondary-rate-limit
277
+ * backoff — this cap keeps us from spraying enough parallel queries to trip it.
278
+ */
279
+ export const MAX_VERIFY_CONCURRENCY = 5;
280
+ /**
281
+ * Verify many issues with bounded concurrent fan-out of the single-issue
282
+ * {@link fetchIssueVerification}.
283
+ *
284
+ * This is a deliberately thin worker pool, NOT one aliased GraphQL query:
285
+ * per-issue timeline cursors make aliasing unmanageable, and all-or-nothing
286
+ * failure (one bad issue poisoning the batch) is exactly what we want to
287
+ * avoid. No retry layer is added here — `octokit` is expected to be a
288
+ * ThrottledOctokit whose backoff already covers secondary rate limits.
289
+ *
290
+ * @param octokit - Throttled Octokit instance (see `getOctokit`).
291
+ * @param items - Issues to verify; results align with this order.
292
+ * @param options.concurrency - Desired parallelism; capped at
293
+ * {@link MAX_VERIFY_CONCURRENCY} and floored at 1.
294
+ */
295
+ export async function verifyIssuesBatch(octokit, items, options = {}) {
296
+ const results = new Array(items.length);
297
+ if (items.length === 0)
298
+ return results;
299
+ const requested = options.concurrency ?? MAX_VERIFY_CONCURRENCY;
300
+ // Floor at 1 so a 0/negative/NaN request can never produce zero workers
301
+ // (which would leave the results array full of holes).
302
+ const concurrency = Math.max(1, Math.min(Number.isFinite(requested) ? requested : MAX_VERIFY_CONCURRENCY, MAX_VERIFY_CONCURRENCY, items.length));
303
+ let index = 0;
304
+ async function worker() {
305
+ while (index < items.length) {
306
+ const i = index++;
307
+ const params = items[i];
308
+ try {
309
+ results[i] = { params, verification: await fetchIssueVerification(octokit, params) };
310
+ }
311
+ catch (error) {
312
+ results[i] = { params, error };
313
+ }
314
+ }
315
+ }
316
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
317
+ return results;
318
+ }