@oss-scout/mcp 0.7.0 → 0.9.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.
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  "use strict";
2
3
  var __create = Object.create;
3
4
  var __defProp = Object.defineProperty;
@@ -28888,13 +28889,13 @@ var zodToJsonSchema = (schema, options) => {
28888
28889
  }, true) ?? parseAnyDef(refs)
28889
28890
  }), {}) : void 0;
28890
28891
  const name = typeof options === "string" ? options : options?.nameStrategy === "title" ? void 0 : options?.name;
28891
- const main2 = parseDef(schema._def, name === void 0 ? refs : {
28892
+ const main = parseDef(schema._def, name === void 0 ? refs : {
28892
28893
  ...refs,
28893
28894
  currentPath: [...refs.basePath, refs.definitionPath, name]
28894
28895
  }, false) ?? parseAnyDef(refs);
28895
28896
  const title = typeof options === "object" && options.name !== void 0 && options.nameStrategy === "title" ? options.name : void 0;
28896
28897
  if (title !== void 0) {
28897
- main2.title = title;
28898
+ main.title = title;
28898
28899
  }
28899
28900
  if (refs.flags.hasReferencedOpenAiAnyType) {
28900
28901
  if (!definitions) {
@@ -28915,9 +28916,9 @@ var zodToJsonSchema = (schema, options) => {
28915
28916
  }
28916
28917
  }
28917
28918
  const combined = name === void 0 ? definitions ? {
28918
- ...main2,
28919
+ ...main,
28919
28920
  [refs.definitionPath]: definitions
28920
- } : main2 : {
28921
+ } : main : {
28921
28922
  $ref: [
28922
28923
  ...refs.$refStrategy === "relative" ? [] : refs.basePath,
28923
28924
  refs.definitionPath,
@@ -28925,7 +28926,7 @@ var zodToJsonSchema = (schema, options) => {
28925
28926
  ].join("/"),
28926
28927
  [refs.definitionPath]: {
28927
28928
  ...definitions,
28928
- [name]: main2
28929
+ [name]: main
28929
28930
  }
28930
28931
  };
28931
28932
  if (refs.target === "jsonSchema7") {
@@ -30262,8 +30263,8 @@ var Server = class extends Protocol {
30262
30263
  this._loggingLevels = /* @__PURE__ */ new Map();
30263
30264
  this.LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index]));
30264
30265
  this.isMessageIgnored = (level, sessionId) => {
30265
- const currentLevel = this._loggingLevels.get(sessionId);
30266
- return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level) < this.LOG_LEVEL_SEVERITY.get(currentLevel) : false;
30266
+ const currentLevel2 = this._loggingLevels.get(sessionId);
30267
+ return currentLevel2 ? this.LOG_LEVEL_SEVERITY.get(level) < this.LOG_LEVEL_SEVERITY.get(currentLevel2) : false;
30267
30268
  };
30268
30269
  this._capabilities = options?.capabilities ?? {};
30269
30270
  this._instructions = options?.instructions;
@@ -35449,18 +35450,34 @@ throttling.VERSION = VERSION9;
35449
35450
  throttling.triggersNotification = triggersNotification;
35450
35451
 
35451
35452
  // ../core/dist/core/logger.js
35452
- var debugEnabled = false;
35453
+ var LEVEL_RANK = {
35454
+ silent: 0,
35455
+ warn: 1,
35456
+ info: 2,
35457
+ debug: 3
35458
+ };
35459
+ var currentLevel = "info";
35460
+ function setLogLevel(level) {
35461
+ currentLevel = level;
35462
+ }
35463
+ function shouldLog(level) {
35464
+ return LEVEL_RANK[currentLevel] >= LEVEL_RANK[level];
35465
+ }
35453
35466
  function debug(module2, message, ...args) {
35454
- if (!debugEnabled)
35467
+ if (!shouldLog("debug"))
35455
35468
  return;
35456
35469
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
35457
35470
  console.error(`[${timestamp}] [DEBUG] [${module2}] ${message}`, ...args);
35458
35471
  }
35459
35472
  function info(module2, message, ...args) {
35473
+ if (!shouldLog("info"))
35474
+ return;
35460
35475
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
35461
35476
  console.error(`[${timestamp}] [INFO] [${module2}] ${message}`, ...args);
35462
35477
  }
35463
35478
  function warn(module2, message, ...args) {
35479
+ if (!shouldLog("warn"))
35480
+ return;
35464
35481
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
35465
35482
  console.error(`[${timestamp}] [WARN] [${module2}] ${message}`, ...args);
35466
35483
  }
@@ -35556,10 +35573,15 @@ function isRateLimitError(error48) {
35556
35573
  return true;
35557
35574
  if (status === 403) {
35558
35575
  const msg = errorMessage(error48).toLowerCase();
35559
- return msg.includes("rate limit");
35576
+ return msg.includes("rate limit") || msg.includes("abuse detection");
35560
35577
  }
35561
35578
  return false;
35562
35579
  }
35580
+ function rethrowIfFatal(error48) {
35581
+ if (getHttpStatusCode(error48) === 401 || isRateLimitError(error48)) {
35582
+ throw error48;
35583
+ }
35584
+ }
35563
35585
 
35564
35586
  // ../core/dist/core/utils.js
35565
35587
  function sleep(ms) {
@@ -35583,12 +35605,23 @@ function getCacheDir() {
35583
35605
  return dir;
35584
35606
  }
35585
35607
  function extractRepoFromUrl(url2) {
35586
- const apiMatch = url2.match(/api\.github\.com\/repos\/([^/]+\/[^/]+)/);
35587
- if (apiMatch)
35588
- return apiMatch[1];
35589
- const webMatch = url2.match(/github\.com\/([^/]+\/[^/]+)/);
35590
- if (webMatch)
35591
- return webMatch[1];
35608
+ let parsed;
35609
+ try {
35610
+ parsed = new URL(url2);
35611
+ } catch {
35612
+ return null;
35613
+ }
35614
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
35615
+ const segments = parsed.pathname.split("/").filter(Boolean);
35616
+ if (host === "api.github.com") {
35617
+ if (segments[0] === "repos" && segments.length >= 3) {
35618
+ return `${segments[1]}/${segments[2]}`;
35619
+ }
35620
+ return null;
35621
+ }
35622
+ if (host === "github.com" && segments.length >= 2) {
35623
+ return `${segments[0]}/${segments[1]}`;
35624
+ }
35592
35625
  return null;
35593
35626
  }
35594
35627
  var OWNER_PATTERN = /^[a-zA-Z0-9_-]+$/;
@@ -35597,25 +35630,29 @@ function isValidOwnerRepo(owner, repo) {
35597
35630
  return OWNER_PATTERN.test(owner) && REPO_PATTERN.test(repo);
35598
35631
  }
35599
35632
  function parseGitHubUrl(url2) {
35600
- if (!url2.startsWith("https://github.com/"))
35633
+ const normalized = /^(?:www\.)?github\.com\//i.test(url2) ? `https://${url2}` : url2;
35634
+ let parsed;
35635
+ try {
35636
+ parsed = new URL(normalized);
35637
+ } catch {
35601
35638
  return null;
35602
- const prMatch = url2.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
35603
- if (prMatch) {
35604
- const owner = prMatch[1];
35605
- const repo = prMatch[2];
35606
- if (!isValidOwnerRepo(owner, repo))
35607
- return null;
35608
- return { owner, repo, number: parseInt(prMatch[3], 10), type: "pull" };
35609
- }
35610
- const issueMatch = url2.match(/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
35611
- if (issueMatch) {
35612
- const owner = issueMatch[1];
35613
- const repo = issueMatch[2];
35614
- if (!isValidOwnerRepo(owner, repo))
35615
- return null;
35616
- return { owner, repo, number: parseInt(issueMatch[3], 10), type: "issues" };
35617
35639
  }
35618
- return null;
35640
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
35641
+ return null;
35642
+ const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
35643
+ if (host !== "github.com")
35644
+ return null;
35645
+ const segments = parsed.pathname.split("/").filter(Boolean);
35646
+ if (segments.length !== 4)
35647
+ return null;
35648
+ const [owner, repo, type, num] = segments;
35649
+ if (type !== "pull" && type !== "issues")
35650
+ return null;
35651
+ if (!isValidOwnerRepo(owner, repo))
35652
+ return null;
35653
+ if (!/^\d+$/.test(num))
35654
+ return null;
35655
+ return { owner, repo, number: parseInt(num, 10), type };
35619
35656
  }
35620
35657
  function daysBetween(from, to = /* @__PURE__ */ new Date()) {
35621
35658
  return Math.max(0, Math.floor((to.getTime() - from.getTime()) / (1e3 * 60 * 60 * 24)));
@@ -35626,8 +35663,9 @@ function getGitHubToken() {
35626
35663
  if (tokenFetchAttempted)
35627
35664
  return null;
35628
35665
  tokenFetchAttempted = true;
35629
- if (process.env.GITHUB_TOKEN) {
35630
- cachedGitHubToken = process.env.GITHUB_TOKEN;
35666
+ const envToken = process.env.GITHUB_TOKEN?.trim();
35667
+ if (envToken) {
35668
+ cachedGitHubToken = envToken;
35631
35669
  return cachedGitHubToken;
35632
35670
  }
35633
35671
  try {
@@ -35642,7 +35680,7 @@ function getGitHubToken() {
35642
35680
  return cachedGitHubToken;
35643
35681
  }
35644
35682
  } catch (err) {
35645
- debug(MODULE2, "gh auth token failed", err);
35683
+ debug(MODULE2, `gh auth token failed: ${errorMessage(err)}`);
35646
35684
  }
35647
35685
  return null;
35648
35686
  }
@@ -35662,6 +35700,8 @@ var SearchBudgetTracker = class {
35662
35700
  resetAt = 0;
35663
35701
  /** Total calls recorded since init (for diagnostics). */
35664
35702
  totalCalls = 0;
35703
+ /** Calls made since the last known quota reset (external accounting). */
35704
+ callsSinceReset = 0;
35665
35705
  /**
35666
35706
  * Initialize with pre-flight rate limit data from GitHub.
35667
35707
  */
@@ -35670,6 +35710,7 @@ var SearchBudgetTracker = class {
35670
35710
  this.resetAt = new Date(resetAt).getTime();
35671
35711
  this.callTimestamps = [];
35672
35712
  this.totalCalls = 0;
35713
+ this.callsSinceReset = 0;
35673
35714
  debug(MODULE3, `Initialized: ${remaining} remaining, resets at ${new Date(this.resetAt).toLocaleTimeString()}`);
35674
35715
  }
35675
35716
  /**
@@ -35678,8 +35719,28 @@ var SearchBudgetTracker = class {
35678
35719
  recordCall() {
35679
35720
  this.callTimestamps.push(Date.now());
35680
35721
  this.totalCalls++;
35722
+ this.callsSinceReset++;
35681
35723
  this.pruneOldTimestamps();
35682
35724
  }
35725
+ /**
35726
+ * Replenish the external budget once GitHub's quota window has reset.
35727
+ * Without this, the pre-flight remaining acted as a never-replenishing
35728
+ * run-lifetime total, throttling long multi-phase runs to ~1 call/min
35729
+ * even though GitHub fully resets every 60 seconds (#119).
35730
+ */
35731
+ refreshExternalBudget() {
35732
+ if (this.resetAt <= 0)
35733
+ return;
35734
+ const now = Date.now();
35735
+ if (now < this.resetAt)
35736
+ return;
35737
+ this.knownRemaining = SEARCH_RATE_LIMIT;
35738
+ this.callsSinceReset = 0;
35739
+ while (this.resetAt <= now) {
35740
+ this.resetAt += SEARCH_WINDOW_MS;
35741
+ }
35742
+ debug(MODULE3, "Quota window reset; external search budget replenished");
35743
+ }
35683
35744
  /**
35684
35745
  * Remove timestamps older than the sliding window.
35685
35746
  */
@@ -35701,8 +35762,9 @@ var SearchBudgetTracker = class {
35701
35762
  * and the pre-flight remaining quota from GitHub.
35702
35763
  */
35703
35764
  getEffectiveBudget() {
35765
+ this.refreshExternalBudget();
35704
35766
  const localBudget = EFFECTIVE_BUDGET - this.callTimestamps.length;
35705
- const externalBudget = this.knownRemaining - this.totalCalls;
35767
+ const externalBudget = this.knownRemaining - this.callsSinceReset;
35706
35768
  return Math.max(0, Math.min(localBudget, externalBudget));
35707
35769
  }
35708
35770
  /**
@@ -35725,6 +35787,12 @@ var SearchBudgetTracker = class {
35725
35787
  }
35726
35788
  const oldestInWindow = this.callTimestamps[0];
35727
35789
  if (!oldestInWindow) {
35790
+ const untilReset = this.resetAt - Date.now();
35791
+ if (this.resetAt > 0 && untilReset > 0) {
35792
+ debug(MODULE3, `External quota exhausted, waiting ${untilReset}ms for reset`);
35793
+ await sleep(untilReset + 100);
35794
+ continue;
35795
+ }
35728
35796
  return;
35729
35797
  }
35730
35798
  const waitUntil = oldestInWindow + SEARCH_WINDOW_MS;
@@ -35770,12 +35838,7 @@ var SCOPE_LABELS = {
35770
35838
  };
35771
35839
 
35772
35840
  // ../core/dist/core/schemas.js
35773
- var IssueStatusSchema = external_exports3.enum([
35774
- "candidate",
35775
- "claimed",
35776
- "in_progress",
35777
- "pr_submitted"
35778
- ]);
35841
+ var IssueStatusSchema = external_exports3.enum(["candidate"]);
35779
35842
  var ProjectCategorySchema = external_exports3.enum([
35780
35843
  "nonprofit",
35781
35844
  "devtools",
@@ -35802,12 +35865,12 @@ var CONCRETE_STRATEGIES = [
35802
35865
  "broad",
35803
35866
  "maintained"
35804
35867
  ];
35805
- var RepoSignalsSchema = external_exports3.object({
35868
+ var RepoSignalsSchema = external_exports3.looseObject({
35806
35869
  hasActiveMaintainers: external_exports3.boolean(),
35807
35870
  isResponsive: external_exports3.boolean(),
35808
35871
  hasHostileComments: external_exports3.boolean()
35809
35872
  });
35810
- var RepoScoreSchema = external_exports3.object({
35873
+ var RepoScoreSchema = external_exports3.looseObject({
35811
35874
  repo: external_exports3.string(),
35812
35875
  score: external_exports3.number(),
35813
35876
  mergedPRCount: external_exports3.number(),
@@ -35819,17 +35882,17 @@ var RepoScoreSchema = external_exports3.object({
35819
35882
  stargazersCount: external_exports3.number().optional(),
35820
35883
  language: external_exports3.string().nullable().optional()
35821
35884
  });
35822
- var StoredMergedPRSchema = external_exports3.object({
35885
+ var StoredMergedPRSchema = external_exports3.looseObject({
35823
35886
  url: external_exports3.string(),
35824
35887
  title: external_exports3.string(),
35825
35888
  mergedAt: external_exports3.string()
35826
35889
  });
35827
- var StoredClosedPRSchema = external_exports3.object({
35890
+ var StoredClosedPRSchema = external_exports3.looseObject({
35828
35891
  url: external_exports3.string(),
35829
35892
  title: external_exports3.string(),
35830
35893
  closedAt: external_exports3.string()
35831
35894
  });
35832
- var StoredOpenPRSchema = external_exports3.object({
35895
+ var StoredOpenPRSchema = external_exports3.looseObject({
35833
35896
  url: external_exports3.string(),
35834
35897
  title: external_exports3.string(),
35835
35898
  openedAt: external_exports3.string()
@@ -35889,7 +35952,7 @@ var TrackedIssueSchema = external_exports3.object({
35889
35952
  vetted: external_exports3.boolean(),
35890
35953
  vettingResult: IssueVettingResultSchema.optional()
35891
35954
  });
35892
- var SkippedIssueSchema = external_exports3.object({
35955
+ var SkippedIssueSchema = external_exports3.looseObject({
35893
35956
  url: external_exports3.string(),
35894
35957
  repo: external_exports3.string(),
35895
35958
  number: external_exports3.number(),
@@ -35897,7 +35960,7 @@ var SkippedIssueSchema = external_exports3.object({
35897
35960
  skippedAt: external_exports3.string()
35898
35961
  });
35899
35962
  var HorizonSchema = external_exports3.enum(["quick-win", "bigger-bet"]);
35900
- var SavedCandidateSchema = external_exports3.object({
35963
+ var SavedCandidateSchema = external_exports3.looseObject({
35901
35964
  issueUrl: external_exports3.string(),
35902
35965
  repo: external_exports3.string(),
35903
35966
  number: external_exports3.number(),
@@ -35905,14 +35968,23 @@ var SavedCandidateSchema = external_exports3.object({
35905
35968
  labels: external_exports3.array(external_exports3.string()),
35906
35969
  recommendation: external_exports3.enum(["approve", "skip", "needs_review"]),
35907
35970
  viabilityScore: external_exports3.number(),
35908
- searchPriority: external_exports3.string(),
35971
+ // Tightened from z.string() to the actual 3-value union (#158). `.catch`
35972
+ // keeps old persisted state loadable: any unrecognized stored value (or a
35973
+ // future-version value) decodes to "normal" instead of failing the parse.
35974
+ searchPriority: external_exports3.enum(["merged_pr", "starred", "normal"]).catch("normal"),
35909
35975
  firstSeenAt: external_exports3.string(),
35910
35976
  lastSeenAt: external_exports3.string(),
35911
35977
  lastScore: external_exports3.number(),
35912
- horizon: HorizonSchema.optional()
35978
+ horizon: HorizonSchema.optional(),
35979
+ /**
35980
+ * Availability status recorded by the previous `vet-list` run, so the next
35981
+ * run can report transitions ("was available, now claimed") instead of
35982
+ * re-diffing full snapshots (#165). "error" is never stored.
35983
+ */
35984
+ lastStatus: external_exports3.enum(["still_available", "claimed", "closed", "has_pr"]).optional()
35913
35985
  });
35914
35986
  var PersistenceModeSchema = external_exports3.enum(["local", "gist"]);
35915
- var ScoutPreferencesSchema = external_exports3.object({
35987
+ var ScoutPreferencesSchema = external_exports3.looseObject({
35916
35988
  githubUsername: external_exports3.string().default(""),
35917
35989
  languages: external_exports3.array(external_exports3.string()).default(["any"]),
35918
35990
  labels: external_exports3.array(external_exports3.string()).default(["good first issue", "help wanted"]),
@@ -35928,8 +36000,22 @@ var ScoutPreferencesSchema = external_exports3.object({
35928
36000
  interPhaseDelayMs: external_exports3.number().min(0).max(12e4).default(3e4),
35929
36001
  persistence: PersistenceModeSchema.default("local"),
35930
36002
  defaultStrategy: external_exports3.array(SearchStrategySchema).optional(),
36003
+ /**
36004
+ * Persisted personalization defaults (#168). The `--prefer-languages`,
36005
+ * `--prefer-repos`, and `--diversity-ratio` search flags override these when
36006
+ * passed, so a stored preference removes the need to retype the boost every
36007
+ * search. Empty / 0 disables the corresponding signal (same as the flags).
36008
+ */
36009
+ preferLanguages: external_exports3.array(external_exports3.string()).default([]),
36010
+ preferRepos: external_exports3.array(external_exports3.string()).default([]),
36011
+ diversityRatio: external_exports3.number().min(0).max(1).default(0),
35931
36012
  broadPhaseDelayMs: external_exports3.number().min(0).max(3e5).default(9e4),
35932
- skipBroadWhenSufficientResults: external_exports3.number().int().min(0).max(100).default(15),
36013
+ /**
36014
+ * Skip the expensive broad phase once this many candidates were found by
36015
+ * the cheaper phases. Clamped at runtime to maxResults - 1 so it stays
36016
+ * satisfiable; 0 disables skipping entirely.
36017
+ */
36018
+ skipBroadWhenSufficientResults: external_exports3.number().int().min(0).max(100).default(8),
35933
36019
  /**
35934
36020
  * Optional Ollama model id used for SLM pre-triage during vetting
35935
36021
  * (oss-autopilot#1122). Empty disables the feature. Recommended values:
@@ -35956,7 +36042,7 @@ var ScoutPreferencesSchema = external_exports3.object({
35956
36042
  */
35957
36043
  featuresSplitRatio: external_exports3.number().min(0).max(1).default(0.6)
35958
36044
  });
35959
- var ScoutStateSchema = external_exports3.object({
36045
+ var ScoutStateSchema = external_exports3.looseObject({
35960
36046
  version: external_exports3.literal(1),
35961
36047
  preferences: ScoutPreferencesSchema.default(() => ScoutPreferencesSchema.parse({})),
35962
36048
  repoScores: external_exports3.record(external_exports3.string(), RepoScoreSchema).default({}),
@@ -35967,10 +36053,30 @@ var ScoutStateSchema = external_exports3.object({
35967
36053
  openPRs: external_exports3.array(StoredOpenPRSchema).default([]),
35968
36054
  savedResults: external_exports3.array(SavedCandidateSchema).default([]),
35969
36055
  skippedIssues: external_exports3.array(SkippedIssueSchema).default([]),
36056
+ /**
36057
+ * Deletion tombstones (#117). Without these, the gist union-merge would
36058
+ * resurrect any saved result or skipped issue removed on one machine the
36059
+ * next time it merged against another machine's copy that still had it.
36060
+ * A tombstone suppresses an item across a merge until the item is
36061
+ * re-added with a newer timestamp. Pruned after 90 days.
36062
+ */
36063
+ tombstones: external_exports3.array(external_exports3.looseObject({
36064
+ url: external_exports3.string(),
36065
+ removedAt: external_exports3.string()
36066
+ })).default([]),
36067
+ /**
36068
+ * When preferences were last changed (#117). The gist merge previously
36069
+ * always took the remote preferences, silently reverting a local edit;
36070
+ * the side with the fresher timestamp now wins.
36071
+ */
36072
+ preferencesUpdatedAt: external_exports3.string().optional(),
35970
36073
  lastSearchAt: external_exports3.string().optional(),
35971
36074
  lastRunAt: external_exports3.string().default(() => (/* @__PURE__ */ new Date()).toISOString()),
35972
36075
  gistId: external_exports3.string().optional()
35973
36076
  });
36077
+ function parseScoutState(raw) {
36078
+ return ScoutStateSchema.parse(raw);
36079
+ }
35974
36080
 
35975
36081
  // ../core/dist/core/issue-filtering.js
35976
36082
  var DOC_ONLY_LABELS = /* @__PURE__ */ new Set([
@@ -36268,6 +36374,10 @@ var fs2 = __toESM(require("fs"), 1);
36268
36374
  var path2 = __toESM(require("path"), 1);
36269
36375
  var crypto = __toESM(require("crypto"), 1);
36270
36376
  var MODULE4 = "http-cache";
36377
+ var CACHE_SCHEMA_VERSION = "v1";
36378
+ function versionedCacheKey(key) {
36379
+ return `${CACHE_SCHEMA_VERSION}:${key}`;
36380
+ }
36271
36381
  var DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
36272
36382
  var HttpCache = class {
36273
36383
  cacheDir;
@@ -36290,13 +36400,21 @@ var HttpCache = class {
36290
36400
  * (e.g., caching aggregated results from paginated API calls).
36291
36401
  */
36292
36402
  getIfFresh(key, maxAgeMs) {
36403
+ return this.getEntryIfFresh(key, maxAgeMs)?.body ?? null;
36404
+ }
36405
+ /**
36406
+ * Like {@link getIfFresh}, but returns the whole entry so callers can
36407
+ * distinguish "no fresh entry" (null) from a legitimately cached falsy
36408
+ * body (`0`, `""`, `false`, `null`).
36409
+ */
36410
+ getEntryIfFresh(key, maxAgeMs) {
36293
36411
  const entry = this.get(key);
36294
36412
  if (!entry)
36295
36413
  return null;
36296
36414
  const age = Date.now() - new Date(entry.cachedAt).getTime();
36297
36415
  if (!Number.isFinite(age) || age < 0 || age > maxAgeMs)
36298
36416
  return null;
36299
- return entry.body;
36417
+ return entry;
36300
36418
  }
36301
36419
  /**
36302
36420
  * Look up a cached response. Returns `null` if no cache entry exists.
@@ -36447,12 +36565,21 @@ function getHttpCache() {
36447
36565
  }
36448
36566
  return _httpCache;
36449
36567
  }
36450
- async function cachedRequest(cache, url2, fetcher) {
36451
- const existing = cache.getInflight(url2);
36568
+ async function withInflightDedup(cache, key, fn) {
36569
+ const existing = cache.getInflight(key);
36452
36570
  if (existing) {
36453
- debug(MODULE4, `Dedup hit for ${url2}`);
36571
+ debug(MODULE4, `Dedup hit for ${key}`);
36454
36572
  return await existing;
36455
36573
  }
36574
+ const promise2 = fn();
36575
+ const cleanup = cache.setInflight(key, promise2);
36576
+ try {
36577
+ return await promise2;
36578
+ } finally {
36579
+ cleanup();
36580
+ }
36581
+ }
36582
+ async function cachedRequest(cache, url2, fetcher) {
36456
36583
  const doFetch = async () => {
36457
36584
  const extraHeaders = {};
36458
36585
  const cached2 = cache.get(url2);
@@ -36473,28 +36600,34 @@ async function cachedRequest(cache, url2, fetcher) {
36473
36600
  debug(MODULE4, `304 cache hit for ${url2}`);
36474
36601
  return freshCached.body;
36475
36602
  }
36603
+ debug(MODULE4, `304 but cache entry vanished for ${url2}, refetching`);
36604
+ const response = await fetcher({});
36605
+ const etag = response.headers?.["etag"];
36606
+ if (etag) {
36607
+ cache.set(url2, etag, response.data);
36608
+ }
36609
+ return response.data;
36476
36610
  }
36477
36611
  throw err;
36478
36612
  }
36479
36613
  };
36480
- const promise2 = doFetch();
36481
- const cleanup = cache.setInflight(url2, promise2);
36482
- try {
36483
- const result = await promise2;
36484
- return result;
36485
- } finally {
36486
- cleanup();
36487
- }
36614
+ return withInflightDedup(cache, url2, doFetch);
36488
36615
  }
36489
36616
  async function cachedTimeBased(cache, key, maxAgeMs, fetcher) {
36490
- const cached2 = cache.getIfFresh(key, maxAgeMs);
36617
+ const cached2 = cache.getEntryIfFresh(key, maxAgeMs);
36491
36618
  if (cached2) {
36492
36619
  debug(MODULE4, `Time-based cache hit for ${key}`);
36493
- return cached2;
36620
+ return cached2.body;
36494
36621
  }
36495
- const result = await fetcher();
36496
- cache.set(key, "", result);
36497
- return result;
36622
+ return withInflightDedup(cache, key, async () => {
36623
+ const fresh = cache.getEntryIfFresh(key, maxAgeMs);
36624
+ if (fresh) {
36625
+ return fresh.body;
36626
+ }
36627
+ const result = await fetcher();
36628
+ cache.set(key, "", result);
36629
+ return result;
36630
+ });
36498
36631
  }
36499
36632
  function isNotModifiedError(err) {
36500
36633
  return getHttpStatusCode(err) === 304;
@@ -36531,23 +36664,36 @@ function buildLinkedPRFromTimelineEvent(e, context) {
36531
36664
  };
36532
36665
  }
36533
36666
  var MODULE5 = "issue-eligibility";
36534
- var CLAIM_PHRASES = [
36535
- "i'm working on this",
36536
- "i am working on this",
36537
- "i'll take this",
36538
- "i will take this",
36539
- "working on it",
36540
- "i'd like to work on",
36541
- "i would like to work on",
36542
- "can i work on",
36543
- "may i work on",
36544
- "assigned to me",
36545
- "i'm on it",
36546
- "i'll submit a pr",
36547
- "i will submit a pr",
36548
- "working on a fix",
36549
- "working on a pr"
36667
+ var ISSUE_OBJECT = String.raw`(?:this\b|it\b|that\b|the\b|#\d+|issue\b)`;
36668
+ var FIRST_PERSON_CLAIM_PATTERNS = [
36669
+ new RegExp(String.raw`\bi\s*(?:'ll|will) take ${ISSUE_OBJECT}`),
36670
+ new RegExp(String.raw`\bi\s*(?:'d|would) (?:like|love) to work on ${ISSUE_OBJECT}`),
36671
+ /\bi\s*(?:'m|am) on it\b/,
36672
+ /\bi\s*(?:'ll|will) submit a pr\b/,
36673
+ /\bassigned to me\b/
36674
+ ];
36675
+ var GENERIC_WORKING_PATTERNS = [
36676
+ /\bworking on (?:this|it)\b/,
36677
+ /\bworking on a (?:fix|pr)\b/
36550
36678
  ];
36679
+ var PERMISSION_CLAIM_PATTERN = new RegExp(String.raw`\b(?:can|may|could) i (?:work on|take) ${ISSUE_OBJECT}`);
36680
+ var NON_CLAIM_SUBJECTS = /\b(?:anyone|anybody|someone|somebody|who|whoever|nobody|no[- ]?one|not)\b/;
36681
+ function commentClaimsIssue(body) {
36682
+ const clauses = body.toLowerCase().split(/(?<=[.!?])|\n+/);
36683
+ for (const clause of clauses) {
36684
+ if (PERMISSION_CLAIM_PATTERN.test(clause))
36685
+ return true;
36686
+ if (clause.trimEnd().endsWith("?"))
36687
+ continue;
36688
+ if (FIRST_PERSON_CLAIM_PATTERNS.some((p) => p.test(clause)))
36689
+ return true;
36690
+ if (NON_CLAIM_SUBJECTS.test(clause))
36691
+ continue;
36692
+ if (GENERIC_WORKING_PATTERNS.some((p) => p.test(clause)))
36693
+ return true;
36694
+ }
36695
+ return false;
36696
+ }
36551
36697
  async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
36552
36698
  try {
36553
36699
  const timeline = await paginateAll((page) => octokit.issues.listEventsForTimeline({
@@ -36572,9 +36718,7 @@ async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
36572
36718
  }
36573
36719
  return { passed: linkedPRCount === 0, linkedPR };
36574
36720
  } catch (error48) {
36575
- if (getHttpStatusCode(error48) === 401 || isRateLimitError(error48)) {
36576
- throw error48;
36577
- }
36721
+ rethrowIfFatal(error48);
36578
36722
  const errMsg = errorMessage(error48);
36579
36723
  warn(MODULE5, `Failed to check for existing PRs on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming no existing PR.`);
36580
36724
  return { passed: true, inconclusive: true, reason: errMsg, linkedPR: null };
@@ -36583,56 +36727,60 @@ async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
36583
36727
  var MERGED_PR_CACHE_TTL_MS = 15 * 60 * 1e3;
36584
36728
  async function checkUserMergedPRsInRepo(octokit, owner, repo) {
36585
36729
  const cache = getHttpCache();
36586
- const cacheKey = `merged-prs:${owner}/${repo}`;
36587
- const cached2 = cache.getIfFresh(cacheKey, MERGED_PR_CACHE_TTL_MS);
36588
- if (cached2 != null && typeof cached2 === "number") {
36589
- return cached2;
36590
- }
36591
- try {
36592
- const tracker = getSearchBudgetTracker();
36593
- await tracker.waitForBudget();
36594
- try {
36595
- const { data } = await octokit.search.issuesAndPullRequests({
36596
- q: `repo:${owner}/${repo} is:pr is:merged author:@me`,
36597
- per_page: 1
36598
- // We only need total_count
36599
- });
36600
- cache.set(cacheKey, "", data.total_count);
36601
- return data.total_count;
36602
- } finally {
36603
- tracker.recordCall();
36730
+ const cacheKey = versionedCacheKey(`merged-prs:${owner}/${repo}`);
36731
+ return withInflightDedup(cache, cacheKey, async () => {
36732
+ const cached2 = cache.getIfFresh(cacheKey, MERGED_PR_CACHE_TTL_MS);
36733
+ if (cached2 != null && typeof cached2 === "number") {
36734
+ return cached2;
36604
36735
  }
36605
- } catch (error48) {
36606
- if (getHttpStatusCode(error48) === 401 || isRateLimitError(error48)) {
36607
- throw error48;
36736
+ try {
36737
+ const tracker = getSearchBudgetTracker();
36738
+ await tracker.waitForBudget();
36739
+ try {
36740
+ const { data } = await octokit.search.issuesAndPullRequests({
36741
+ q: `repo:${owner}/${repo} is:pr is:merged author:@me`,
36742
+ per_page: 1
36743
+ // We only need total_count
36744
+ });
36745
+ cache.set(cacheKey, "", data.total_count);
36746
+ return data.total_count;
36747
+ } finally {
36748
+ tracker.recordCall();
36749
+ }
36750
+ } catch (error48) {
36751
+ rethrowIfFatal(error48);
36752
+ const errMsg = errorMessage(error48);
36753
+ warn(MODULE5, `Could not check merged PRs in ${owner}/${repo}: ${errMsg}. Treating as unknown.`);
36754
+ return null;
36608
36755
  }
36609
- const errMsg = errorMessage(error48);
36610
- warn(MODULE5, `Could not check merged PRs in ${owner}/${repo}: ${errMsg}. Defaulting to 0.`);
36611
- return 0;
36612
- }
36756
+ });
36613
36757
  }
36614
36758
  async function checkNotClaimed(octokit, owner, repo, issueNumber, commentCount) {
36615
36759
  if (commentCount === 0)
36616
36760
  return { passed: true };
36617
36761
  try {
36618
- const comments = await octokit.paginate(octokit.issues.listComments, {
36619
- owner,
36620
- repo,
36621
- issue_number: issueNumber,
36622
- per_page: 100
36623
- }, (response) => response.data);
36624
- const recentComments = comments.slice(-100);
36762
+ const PER_PAGE = 100;
36763
+ const lastPage = Math.max(1, Math.ceil(commentCount / PER_PAGE));
36764
+ const pagesToFetch = lastPage > 1 ? [lastPage - 1, lastPage] : [1];
36765
+ const recentComments = [];
36766
+ for (const page of pagesToFetch) {
36767
+ const response = await octokit.issues.listComments({
36768
+ owner,
36769
+ repo,
36770
+ issue_number: issueNumber,
36771
+ per_page: PER_PAGE,
36772
+ page
36773
+ });
36774
+ recentComments.push(...response.data);
36775
+ }
36625
36776
  for (const comment of recentComments) {
36626
- const body = (comment.body || "").toLowerCase();
36627
- if (CLAIM_PHRASES.some((phrase) => body.includes(phrase))) {
36777
+ if (commentClaimsIssue(comment.body || "")) {
36628
36778
  return { passed: false };
36629
36779
  }
36630
36780
  }
36631
36781
  return { passed: true };
36632
36782
  } catch (error48) {
36633
- if (getHttpStatusCode(error48) === 401 || isRateLimitError(error48)) {
36634
- throw error48;
36635
- }
36783
+ rethrowIfFatal(error48);
36636
36784
  const errMsg = errorMessage(error48);
36637
36785
  warn(MODULE5, `Failed to check claim status on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming not claimed.`);
36638
36786
  return { passed: true, inconclusive: true, reason: errMsg };
@@ -36705,19 +36853,11 @@ async function checkProjectHealth(octokit, owner, repo) {
36705
36853
  };
36706
36854
  });
36707
36855
  } catch (error48) {
36708
- if (getHttpStatusCode(error48) === 401 || isRateLimitError(error48)) {
36709
- throw error48;
36710
- }
36856
+ rethrowIfFatal(error48);
36711
36857
  const errMsg = errorMessage(error48);
36712
36858
  warn(MODULE6, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
36713
36859
  return {
36714
36860
  repo: `${owner}/${repo}`,
36715
- lastCommitAt: "",
36716
- daysSinceLastCommit: 999,
36717
- openIssuesCount: 0,
36718
- avgIssueResponseDays: 0,
36719
- ciStatus: "unknown",
36720
- isActive: false,
36721
36861
  checkFailed: true,
36722
36862
  failureReason: errMsg
36723
36863
  };
@@ -36729,6 +36869,20 @@ async function fetchContributionGuidelines(octokit, owner, repo) {
36729
36869
  if (cached2 && Date.now() - cached2.fetchedAt < CACHE_TTL_MS) {
36730
36870
  return cached2.guidelines;
36731
36871
  }
36872
+ const inflight = guidelinesInflight.get(cacheKey);
36873
+ if (inflight)
36874
+ return inflight;
36875
+ const promise2 = fetchContributionGuidelinesUncached(octokit, owner, repo);
36876
+ guidelinesInflight.set(cacheKey, promise2);
36877
+ try {
36878
+ return await promise2;
36879
+ } finally {
36880
+ guidelinesInflight.delete(cacheKey);
36881
+ }
36882
+ }
36883
+ var guidelinesInflight = /* @__PURE__ */ new Map();
36884
+ async function fetchContributionGuidelinesUncached(octokit, owner, repo) {
36885
+ const cacheKey = `${owner}/${repo}`;
36732
36886
  const filesToCheck = [
36733
36887
  "CONTRIBUTING.md",
36734
36888
  ".github/CONTRIBUTING.md",
@@ -36776,7 +36930,7 @@ function parseContributionGuidelines(content) {
36776
36930
  };
36777
36931
  const lowerContent = content.toLowerCase();
36778
36932
  if (lowerContent.includes("branch")) {
36779
- const branchMatch = content.match(/branch[^\n]*(?:named?|format|convention)[^\n]*[`"]([^`"]+)[`"]/i);
36933
+ const branchMatch = content.match(/branch[^\n]{0,200}?(?:named?|format|convention)[^\n]{0,200}?[`"]([^`"\n]{1,100})[`"]/i);
36780
36934
  if (branchMatch) {
36781
36935
  guidelines.branchNamingConvention = branchMatch[1];
36782
36936
  }
@@ -36784,7 +36938,7 @@ function parseContributionGuidelines(content) {
36784
36938
  if (lowerContent.includes("conventional commit")) {
36785
36939
  guidelines.commitMessageFormat = "conventional commits";
36786
36940
  } else if (lowerContent.includes("commit message")) {
36787
- const commitMatch = content.match(/commit message[^\n]*[`"]([^`"]+)[`"]/i);
36941
+ const commitMatch = content.match(/commit message[^\n]{0,200}?[`"]([^`"\n]{1,100})[`"]/i);
36788
36942
  if (commitMatch) {
36789
36943
  guidelines.commitMessageFormat = commitMatch[1];
36790
36944
  }
@@ -36803,7 +36957,7 @@ function parseContributionGuidelines(content) {
36803
36957
  guidelines.linter = "RuboCop";
36804
36958
  else if (lowerContent.includes("prettier"))
36805
36959
  guidelines.formatter = "Prettier";
36806
- if (lowerContent.includes("cla") || lowerContent.includes("contributor license agreement")) {
36960
+ if (/\bcla\b/.test(lowerContent) || lowerContent.includes("contributor license agreement")) {
36807
36961
  guidelines.claRequired = true;
36808
36962
  }
36809
36963
  return guidelines;
@@ -36887,8 +37041,7 @@ async function fetchFileText(octokit, owner, repo, path5) {
36887
37041
  const status = getHttpStatusCode(error48);
36888
37042
  if (status === 404)
36889
37043
  return { text: null, transient: false };
36890
- if (status === 401 || isRateLimitError(error48))
36891
- throw error48;
37044
+ rethrowIfFatal(error48);
36892
37045
  warn(MODULE7, `Unexpected error fetching ${path5} from ${owner}/${repo}: ${errorMessage(error48)}`);
36893
37046
  return { text: null, transient: true };
36894
37047
  }
@@ -36925,7 +37078,7 @@ function isAntiLLMPolicyResult(value) {
36925
37078
  }
36926
37079
  async function fetchAndScanAntiLLMPolicy(octokit, owner, repo, options) {
36927
37080
  const cache = getHttpCache();
36928
- const cacheKey = `anti-llm-policy:${owner}/${repo}`;
37081
+ const cacheKey = versionedCacheKey(`anti-llm-policy:${owner}/${repo}`);
36929
37082
  const cached2 = cache.getIfFresh(cacheKey, POLICY_SCAN_CACHE_TTL_MS);
36930
37083
  if (isAntiLLMPolicyResult(cached2))
36931
37084
  return cached2;
@@ -36964,6 +37117,76 @@ async function fetchAndScanAntiLLMPolicy(octokit, owner, repo, options) {
36964
37117
  return noMatch;
36965
37118
  }
36966
37119
 
37120
+ // ../core/dist/core/issue-graphql.js
37121
+ var MODULE8 = "issue-graphql";
37122
+ function issueCoreKey(owner, repo, number4) {
37123
+ return `${owner}/${repo}#${number4}`;
37124
+ }
37125
+ function normalizeNode(node) {
37126
+ return {
37127
+ id: node.databaseId,
37128
+ title: node.title,
37129
+ body: node.body ?? "",
37130
+ state: node.state === "CLOSED" ? "closed" : "open",
37131
+ labels: node.labels.nodes.map((l) => l.name),
37132
+ commentCount: node.comments.totalCount,
37133
+ createdAt: node.createdAt,
37134
+ updatedAt: node.updatedAt
37135
+ };
37136
+ }
37137
+ async function prefetchIssueCores(octokit, issues) {
37138
+ const result = /* @__PURE__ */ new Map();
37139
+ if (issues.length === 0)
37140
+ return result;
37141
+ const unique = [
37142
+ ...new Map(issues.map((i) => [issueCoreKey(i.owner, i.repo, i.number), i])).values()
37143
+ ];
37144
+ const varDefs = [];
37145
+ const selections = [];
37146
+ const variables = {};
37147
+ unique.forEach((iss, i) => {
37148
+ varDefs.push(`$o${i}: String!, $n${i}: String!, $num${i}: Int!`);
37149
+ variables[`o${i}`] = iss.owner;
37150
+ variables[`n${i}`] = iss.repo;
37151
+ variables[`num${i}`] = iss.number;
37152
+ selections.push(`i${i}: repository(owner: $o${i}, name: $n${i}) {
37153
+ issue(number: $num${i}) {
37154
+ databaseId
37155
+ title
37156
+ body
37157
+ state
37158
+ labels(first: 100) { nodes { name } }
37159
+ comments { totalCount }
37160
+ createdAt
37161
+ updatedAt
37162
+ }
37163
+ }`);
37164
+ });
37165
+ const query = `query batchIssueCores(${varDefs.join(", ")}) {
37166
+ ${selections.join("\n")}
37167
+ }`;
37168
+ let data;
37169
+ try {
37170
+ data = await octokit.graphql(query, variables);
37171
+ } catch (err) {
37172
+ rethrowIfFatal(err);
37173
+ const partial2 = err.data;
37174
+ if (partial2) {
37175
+ data = partial2;
37176
+ } else {
37177
+ warn(MODULE8, `GraphQL prefetch failed, falling back to REST: ${errorMessage(err)}`);
37178
+ return result;
37179
+ }
37180
+ }
37181
+ unique.forEach((iss, i) => {
37182
+ const node = data?.[`i${i}`]?.issue;
37183
+ if (!node || node.databaseId == null)
37184
+ return;
37185
+ result.set(issueCoreKey(iss.owner, iss.repo, iss.number), normalizeNode(node));
37186
+ });
37187
+ return result;
37188
+ }
37189
+
36967
37190
  // ../core/dist/core/slm-triage.js
36968
37191
  var DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434";
36969
37192
  var DEFAULT_TIMEOUT_MS = 15e3;
@@ -37076,9 +37299,81 @@ function isValidTriageShape(value) {
37076
37299
  }
37077
37300
 
37078
37301
  // ../core/dist/core/issue-vetting.js
37079
- var MODULE8 = "issue-vetting";
37302
+ var MODULE9 = "issue-vetting";
37080
37303
  var MAX_CONCURRENT_VETTING = 3;
37081
37304
  var VETTING_CACHE_TTL_MS = 15 * 60 * 1e3;
37305
+ function deriveRecommendation(input) {
37306
+ const notes = [];
37307
+ const reasonsToApprove = [];
37308
+ const reasonsToSkip = [];
37309
+ if (!input.noExistingPR)
37310
+ notes.push(input.ownPR ? "Your PR is already in flight for this issue" : "Existing PR found for this issue");
37311
+ if (!input.notClaimed)
37312
+ notes.push("Issue appears to be claimed by someone");
37313
+ if (input.existingPRInconclusive) {
37314
+ notes.push(`Could not verify absence of existing PRs: ${input.existingPRReason || "API error"}`);
37315
+ }
37316
+ if (input.claimInconclusive) {
37317
+ notes.push(`Could not verify claim status: ${input.claimReason || "API error"}`);
37318
+ }
37319
+ if (input.projectCheckFailed) {
37320
+ notes.push(`Could not verify project activity: ${input.projectFailureReason || "API error"}`);
37321
+ } else if (!input.projectIsActive) {
37322
+ notes.push("Project may be inactive");
37323
+ }
37324
+ if (!input.clearRequirements)
37325
+ notes.push("Issue requirements are unclear");
37326
+ if (!input.contributionGuidelinesFound)
37327
+ notes.push("No CONTRIBUTING.md found");
37328
+ if (!input.noExistingPR)
37329
+ reasonsToSkip.push(input.ownPR ? "You already have a PR in flight" : "Has existing PR");
37330
+ if (!input.notClaimed)
37331
+ reasonsToSkip.push("Already claimed");
37332
+ if (!input.projectIsActive && !input.projectCheckFailed)
37333
+ reasonsToSkip.push("Inactive project");
37334
+ if (!input.clearRequirements)
37335
+ reasonsToSkip.push("Unclear requirements");
37336
+ if (input.noExistingPR)
37337
+ reasonsToApprove.push("No existing PR");
37338
+ if (input.notClaimed)
37339
+ reasonsToApprove.push("Not claimed");
37340
+ if (input.projectIsActive && !input.projectCheckFailed)
37341
+ reasonsToApprove.push("Active project");
37342
+ if (input.clearRequirements)
37343
+ reasonsToApprove.push("Clear requirements");
37344
+ if (input.contributionGuidelinesFound)
37345
+ reasonsToApprove.push("Has contribution guidelines");
37346
+ if (input.effectiveMergedCount > 0) {
37347
+ reasonsToApprove.push(`Trusted project (${input.effectiveMergedCount} PR${input.effectiveMergedCount > 1 ? "s" : ""} merged)`);
37348
+ }
37349
+ if (input.orgHasMergedPRs) {
37350
+ reasonsToApprove.push(`Org affinity (merged PRs in other ${input.orgName} repos)`);
37351
+ }
37352
+ if (input.matchesCategory) {
37353
+ reasonsToApprove.push("Matches preferred project category");
37354
+ }
37355
+ if (input.issueClosed) {
37356
+ reasonsToSkip.push("Issue is closed");
37357
+ }
37358
+ let recommendation;
37359
+ if (input.issueClosed) {
37360
+ recommendation = "skip";
37361
+ } else if (input.ownPR) {
37362
+ recommendation = "skip";
37363
+ } else if (input.passedAllChecks) {
37364
+ recommendation = "approve";
37365
+ } else if (reasonsToSkip.length > 2) {
37366
+ recommendation = "skip";
37367
+ } else {
37368
+ recommendation = "needs_review";
37369
+ }
37370
+ const hasInconclusiveChecks = input.projectCheckFailed || input.existingPRInconclusive || input.claimInconclusive || input.mergedCountInconclusive;
37371
+ if (recommendation === "approve" && hasInconclusiveChecks) {
37372
+ recommendation = "needs_review";
37373
+ notes.push("Recommendation downgraded: one or more checks were inconclusive");
37374
+ }
37375
+ return { notes, reasonsToApprove, reasonsToSkip, recommendation };
37376
+ }
37082
37377
  var IssueVetter = class {
37083
37378
  octokit;
37084
37379
  stateReader;
@@ -37098,10 +37393,10 @@ var IssueVetter = class {
37098
37393
  async vetIssue(issueUrl, opts) {
37099
37394
  const cache = getHttpCache();
37100
37395
  const sigKey = opts?.featureSignals ? `:r${opts.featureSignals.reactions}c${opts.featureSignals.comments}m${opts.featureSignals.hasMilestone ? 1 : 0}o${opts.featureSignals.onRoadmap ? 1 : 0}w${opts.featureSignals.wontfixNoContributor ? 1 : 0}` : "";
37101
- const cacheKey = `vet:${issueUrl}${sigKey}`;
37396
+ const cacheKey = versionedCacheKey(`vet:${issueUrl}${sigKey}`);
37102
37397
  const cached2 = cache.getIfFresh(cacheKey, VETTING_CACHE_TTL_MS);
37103
37398
  if (cached2 && typeof cached2 === "object" && "issue" in cached2 && "viabilityScore" in cached2) {
37104
- debug(MODULE8, `Vetting cache hit for ${issueUrl}`);
37399
+ debug(MODULE9, `Vetting cache hit for ${issueUrl}`);
37105
37400
  return cached2;
37106
37401
  }
37107
37402
  const parsed = parseGitHubUrl(issueUrl);
@@ -37110,16 +37405,12 @@ var IssueVetter = class {
37110
37405
  }
37111
37406
  const { owner, repo, number: number4 } = parsed;
37112
37407
  const repoFullName = `${owner}/${repo}`;
37113
- const { data: ghIssue } = await this.octokit.issues.get({
37114
- owner,
37115
- repo,
37116
- issue_number: number4
37117
- });
37408
+ const core = opts?.prefetched ?? await this.fetchIssueCore(owner, repo, number4);
37118
37409
  const reposWithMergedPRs = this.stateReader.getReposWithMergedPRs();
37119
37410
  const hasMergedPRsInRepo = reposWithMergedPRs.includes(repoFullName);
37120
37411
  const [existingPRCheck, claimCheck, projectHealth, contributionGuidelines, userMergedPRCount] = await Promise.all([
37121
37412
  checkNoExistingPR(this.octokit, owner, repo, number4),
37122
- checkNotClaimed(this.octokit, owner, repo, number4, ghIssue.comments),
37413
+ checkNotClaimed(this.octokit, owner, repo, number4, core.commentCount),
37123
37414
  checkProjectHealth(this.octokit, owner, repo),
37124
37415
  fetchContributionGuidelines(this.octokit, owner, repo),
37125
37416
  hasMergedPRsInRepo ? Promise.resolve(0) : checkUserMergedPRsInRepo(this.octokit, owner, repo)
@@ -37127,7 +37418,10 @@ var IssueVetter = class {
37127
37418
  const antiLLMPolicy = await fetchAndScanAntiLLMPolicy(this.octokit, owner, repo, { contributingText: contributionGuidelines?.rawContent });
37128
37419
  const noExistingPR = existingPRCheck.passed;
37129
37420
  const notClaimed = claimCheck.passed;
37130
- const clearRequirements = analyzeRequirements(ghIssue.body || "");
37421
+ const username = this.stateReader.getGitHubUsername?.() ?? "";
37422
+ const linkedPR = existingPRCheck.linkedPR;
37423
+ const ownPR = !noExistingPR && !!linkedPR && linkedPR.state === "open" && !linkedPR.merged && username !== "" && linkedPR.author.toLowerCase() === username.toLowerCase();
37424
+ const clearRequirements = analyzeRequirements(core.body);
37131
37425
  const projectActive = projectHealth.checkFailed ? true : projectHealth.isActive;
37132
37426
  const vettingResult = {
37133
37427
  passedAllChecks: noExistingPR && notClaimed && projectActive && clearRequirements,
@@ -37142,89 +37436,52 @@ var IssueVetter = class {
37142
37436
  linkedPR: existingPRCheck.linkedPR,
37143
37437
  notes: []
37144
37438
  };
37145
- if (!noExistingPR)
37146
- vettingResult.notes.push("Existing PR found for this issue");
37147
- if (!notClaimed)
37148
- vettingResult.notes.push("Issue appears to be claimed by someone");
37149
- if (existingPRCheck.inconclusive) {
37150
- vettingResult.notes.push(`Could not verify absence of existing PRs: ${existingPRCheck.reason || "API error"}`);
37151
- }
37152
- if (claimCheck.inconclusive) {
37153
- vettingResult.notes.push(`Could not verify claim status: ${claimCheck.reason || "API error"}`);
37154
- }
37155
- if (projectHealth.checkFailed) {
37156
- vettingResult.notes.push(`Could not verify project activity: ${projectHealth.failureReason || "API error"}`);
37157
- } else if (!projectHealth.isActive) {
37158
- vettingResult.notes.push("Project may be inactive");
37159
- }
37160
- if (!clearRequirements)
37161
- vettingResult.notes.push("Issue requirements are unclear");
37162
- if (!contributionGuidelines)
37163
- vettingResult.notes.push("No CONTRIBUTING.md found");
37164
37439
  const trackedIssue = {
37165
- id: ghIssue.id,
37440
+ id: core.id,
37166
37441
  url: issueUrl,
37167
37442
  repo: repoFullName,
37168
37443
  number: number4,
37169
- title: ghIssue.title,
37444
+ title: core.title,
37170
37445
  status: "candidate",
37171
- labels: ghIssue.labels.map((l) => typeof l === "string" ? l : l.name || ""),
37172
- createdAt: ghIssue.created_at,
37173
- updatedAt: ghIssue.updated_at,
37446
+ labels: core.labels,
37447
+ createdAt: core.createdAt,
37448
+ updatedAt: core.updatedAt,
37174
37449
  vetted: true,
37175
37450
  vettingResult
37176
37451
  };
37177
- const reasonsToSkip = [];
37178
- const reasonsToApprove = [];
37179
- if (!noExistingPR)
37180
- reasonsToSkip.push("Has existing PR");
37181
- if (!notClaimed)
37182
- reasonsToSkip.push("Already claimed");
37183
- if (!projectHealth.isActive && !projectHealth.checkFailed)
37184
- reasonsToSkip.push("Inactive project");
37185
- if (!clearRequirements)
37186
- reasonsToSkip.push("Unclear requirements");
37187
- if (noExistingPR)
37188
- reasonsToApprove.push("No existing PR");
37189
- if (notClaimed)
37190
- reasonsToApprove.push("Not claimed");
37191
- if (projectHealth.isActive && !projectHealth.checkFailed)
37192
- reasonsToApprove.push("Active project");
37193
- if (clearRequirements)
37194
- reasonsToApprove.push("Clear requirements");
37195
- if (contributionGuidelines)
37196
- reasonsToApprove.push("Has contribution guidelines");
37197
- const effectiveMergedCount = hasMergedPRsInRepo ? 1 : userMergedPRCount;
37198
- if (effectiveMergedCount > 0) {
37199
- reasonsToApprove.push(`Trusted project (${effectiveMergedCount} PR${effectiveMergedCount > 1 ? "s" : ""} merged)`);
37200
- }
37452
+ const mergedCountInconclusive = !hasMergedPRsInRepo && userMergedPRCount === null;
37453
+ const effectiveMergedCount = hasMergedPRsInRepo ? 1 : userMergedPRCount ?? 0;
37201
37454
  const orgName = repoFullName.split("/")[0];
37202
37455
  let orgHasMergedPRs = false;
37203
37456
  if (orgName && repoFullName.includes("/")) {
37204
37457
  orgHasMergedPRs = reposWithMergedPRs.some((r) => r.startsWith(orgName + "/") && r !== repoFullName);
37205
37458
  }
37206
- if (orgHasMergedPRs) {
37207
- reasonsToApprove.push(`Org affinity (merged PRs in other ${orgName} repos)`);
37208
- }
37209
- const projectCategories = this.stateReader.getProjectCategories();
37210
- const matchesCategory = repoBelongsToCategory(repoFullName, projectCategories);
37211
- if (matchesCategory) {
37212
- reasonsToApprove.push("Matches preferred project category");
37213
- }
37214
- let recommendation;
37215
- if (vettingResult.passedAllChecks) {
37216
- recommendation = "approve";
37217
- } else if (reasonsToSkip.length > 2) {
37218
- recommendation = "skip";
37219
- } else {
37220
- recommendation = "needs_review";
37221
- }
37222
- const hasInconclusiveChecks = projectHealth.checkFailed || existingPRCheck.inconclusive || claimCheck.inconclusive;
37223
- if (recommendation === "approve" && hasInconclusiveChecks) {
37224
- recommendation = "needs_review";
37225
- vettingResult.notes.push("Recommendation downgraded: one or more checks were inconclusive");
37226
- }
37227
- const repoQualityBonus = calculateRepoQualityBonus(projectHealth.stargazersCount ?? 0, projectHealth.forksCount ?? 0);
37459
+ const matchesCategory = repoBelongsToCategory(repoFullName, this.stateReader.getProjectCategories());
37460
+ const issueClosed = core.state === "closed";
37461
+ const hasInconclusiveChecks = projectHealth.checkFailed || !!existingPRCheck.inconclusive || !!claimCheck.inconclusive || mergedCountInconclusive;
37462
+ const { notes, reasonsToApprove, reasonsToSkip, recommendation } = deriveRecommendation({
37463
+ noExistingPR,
37464
+ ownPR,
37465
+ notClaimed,
37466
+ clearRequirements,
37467
+ contributionGuidelinesFound: !!contributionGuidelines,
37468
+ projectIsActive: projectHealth.checkFailed ? false : projectHealth.isActive,
37469
+ projectCheckFailed: !!projectHealth.checkFailed,
37470
+ projectFailureReason: projectHealth.failureReason,
37471
+ existingPRInconclusive: !!existingPRCheck.inconclusive,
37472
+ existingPRReason: existingPRCheck.inconclusive ? existingPRCheck.reason : void 0,
37473
+ claimInconclusive: !!claimCheck.inconclusive,
37474
+ claimReason: claimCheck.inconclusive ? claimCheck.reason : void 0,
37475
+ mergedCountInconclusive,
37476
+ effectiveMergedCount,
37477
+ orgName,
37478
+ orgHasMergedPRs,
37479
+ matchesCategory,
37480
+ issueClosed,
37481
+ passedAllChecks: vettingResult.passedAllChecks
37482
+ });
37483
+ vettingResult.notes = notes;
37484
+ const repoQualityBonus = projectHealth.checkFailed ? 0 : calculateRepoQualityBonus(projectHealth.stargazersCount ?? 0, projectHealth.forksCount ?? 0);
37228
37485
  if (projectHealth.checkFailed && repoQualityBonus === 0) {
37229
37486
  vettingResult.notes.push("Repo quality bonus unavailable: could not fetch star/fork counts due to API error");
37230
37487
  }
@@ -37235,8 +37492,8 @@ var IssueVetter = class {
37235
37492
  isClaimed: !notClaimed,
37236
37493
  clearRequirements,
37237
37494
  hasContributionGuidelines: !!contributionGuidelines,
37238
- issueUpdatedAt: ghIssue.updated_at,
37239
- closedWithoutMergeCount: 0,
37495
+ issueUpdatedAt: core.updatedAt,
37496
+ closedWithoutMergeCount: this.stateReader.getClosedWithoutMergeCount?.(repoFullName) ?? 0,
37240
37497
  mergedPRCount: effectiveMergedCount,
37241
37498
  orgHasMergedPRs,
37242
37499
  repoQualityBonus,
@@ -37250,22 +37507,20 @@ var IssueVetter = class {
37250
37507
  } else if (starredRepos.includes(repoFullName)) {
37251
37508
  searchPriority = "starred";
37252
37509
  }
37253
- const slmConfig = this.stateReader.getSLMTriageConfig?.() ?? {
37254
- model: "",
37255
- host: ""
37256
- };
37510
+ const slmConfig = this.stateReader.getSLMTriageConfig();
37257
37511
  let slmTriage = null;
37258
- if (slmConfig.model) {
37512
+ if (slmConfig) {
37259
37513
  const slmOpts = { model: slmConfig.model };
37260
37514
  if (slmConfig.host)
37261
37515
  slmOpts.host = slmConfig.host;
37262
37516
  slmTriage = await triageWithSLM(buildTriageInput({
37263
- issue: { ...trackedIssue, body: ghIssue.body ?? "" },
37517
+ issue: { ...trackedIssue, body: core.body },
37264
37518
  linkedPR: existingPRCheck.linkedPR ?? null
37265
37519
  }), slmOpts);
37266
37520
  }
37267
37521
  const result = {
37268
37522
  issue: trackedIssue,
37523
+ issueState: issueClosed ? "closed" : "open",
37269
37524
  vettingResult,
37270
37525
  projectHealth,
37271
37526
  antiLLMPolicy,
@@ -37276,9 +37531,33 @@ var IssueVetter = class {
37276
37531
  viabilityScore,
37277
37532
  searchPriority
37278
37533
  };
37279
- cache.set(cacheKey, "", result);
37534
+ if (!hasInconclusiveChecks) {
37535
+ cache.set(cacheKey, "", result);
37536
+ }
37280
37537
  return result;
37281
37538
  }
37539
+ /**
37540
+ * Fetch a single issue's core fields via REST and normalize them to the same
37541
+ * shape as a GraphQL prefetch (#169). The REST fallback path when no
37542
+ * prefetched core was supplied.
37543
+ */
37544
+ async fetchIssueCore(owner, repo, number4) {
37545
+ const { data: ghIssue } = await this.octokit.issues.get({
37546
+ owner,
37547
+ repo,
37548
+ issue_number: number4
37549
+ });
37550
+ return {
37551
+ id: ghIssue.id,
37552
+ title: ghIssue.title,
37553
+ body: ghIssue.body || "",
37554
+ state: ghIssue.state === "closed" ? "closed" : "open",
37555
+ labels: ghIssue.labels.map((l) => typeof l === "string" ? l : l.name || ""),
37556
+ commentCount: ghIssue.comments,
37557
+ createdAt: ghIssue.created_at,
37558
+ updatedAt: ghIssue.updated_at
37559
+ };
37560
+ }
37282
37561
  /**
37283
37562
  * Vet multiple issues in parallel with concurrency limit
37284
37563
  */
@@ -37289,13 +37568,23 @@ var IssueVetter = class {
37289
37568
  let rateLimitFailures = 0;
37290
37569
  let attemptedCount = 0;
37291
37570
  let firstAuthError = null;
37292
- for (const url2 of urls) {
37571
+ const uniqueUrls = [...new Set(urls)];
37572
+ const prefetched = await prefetchIssueCores(this.octokit, uniqueUrls.flatMap((url2) => {
37573
+ const p = parseGitHubUrl(url2);
37574
+ return p && p.type === "issues" ? [{ owner: p.owner, repo: p.repo, number: p.number }] : [];
37575
+ }));
37576
+ const prefetchFor = (url2) => {
37577
+ const p = parseGitHubUrl(url2);
37578
+ return p && p.type === "issues" ? prefetched.get(issueCoreKey(p.owner, p.repo, p.number)) : void 0;
37579
+ };
37580
+ for (const url2 of uniqueUrls) {
37293
37581
  if (candidates.length >= maxResults)
37294
37582
  break;
37295
37583
  if (firstAuthError)
37296
37584
  break;
37297
37585
  attemptedCount++;
37298
- const task = this.vetIssue(url2).then((candidate) => {
37586
+ const core = prefetchFor(url2);
37587
+ const task = this.vetIssue(url2, core ? { prefetched: core } : void 0).then((candidate) => {
37299
37588
  if (candidates.length < maxResults) {
37300
37589
  if (priority) {
37301
37590
  candidate.searchPriority = priority;
@@ -37311,7 +37600,7 @@ var IssueVetter = class {
37311
37600
  if (isRateLimitError(error48)) {
37312
37601
  rateLimitFailures++;
37313
37602
  }
37314
- warn(MODULE8, `Error vetting issue ${url2}:`, errorMessage(error48));
37603
+ warn(MODULE9, `Error vetting issue ${url2}:`, errorMessage(error48));
37315
37604
  }).finally(() => pending.delete(url2));
37316
37605
  pending.set(url2, task);
37317
37606
  if (pending.size >= MAX_CONCURRENT_VETTING) {
@@ -37321,13 +37610,13 @@ var IssueVetter = class {
37321
37610
  await Promise.allSettled(pending.values());
37322
37611
  if (firstAuthError) {
37323
37612
  if (candidates.length > 0) {
37324
- warn(MODULE8, `Auth failed mid-batch after ${candidates.length} successful vet(s) \u2014 discarding partial results`);
37613
+ warn(MODULE9, `Auth failed mid-batch after ${candidates.length} successful vet(s) \u2014 discarding partial results`);
37325
37614
  }
37326
37615
  throw firstAuthError;
37327
37616
  }
37328
37617
  const allFailed = failedVettingCount === attemptedCount && attemptedCount > 0;
37329
37618
  if (allFailed) {
37330
- warn(MODULE8, `All ${attemptedCount} issue(s) failed vetting. This may indicate a systemic issue (rate limit, auth, network).`);
37619
+ warn(MODULE9, `All ${attemptedCount} issue(s) failed vetting. This may indicate a systemic issue (rate limit, auth, network).`);
37331
37620
  }
37332
37621
  return {
37333
37622
  candidates: candidates.slice(0, maxResults),
@@ -37338,14 +37627,14 @@ var IssueVetter = class {
37338
37627
  };
37339
37628
 
37340
37629
  // ../core/dist/core/search-phases.js
37341
- var MODULE9 = "search-phases";
37630
+ var MODULE10 = "search-phases";
37342
37631
  var GITHUB_MAX_BOOLEAN_OPS = 5;
37343
37632
  var INTER_QUERY_DELAY_MS = 2e3;
37344
37633
  function chunkLabels(labels, reservedOps = 0) {
37345
37634
  const maxPerChunk = GITHUB_MAX_BOOLEAN_OPS - reservedOps + 1;
37346
37635
  if (maxPerChunk < 1) {
37347
37636
  if (labels.length > 0) {
37348
- warn(MODULE9, `Label filtering disabled: ${reservedOps} repo/org ORs exceed GitHub's ${GITHUB_MAX_BOOLEAN_OPS} operator limit. All ${labels.length} label(s) dropped from query.`);
37637
+ warn(MODULE10, `Label filtering disabled: ${reservedOps} repo/org ORs exceed GitHub's ${GITHUB_MAX_BOOLEAN_OPS} operator limit. All ${labels.length} label(s) dropped from query.`);
37349
37638
  }
37350
37639
  return [[]];
37351
37640
  }
@@ -37355,7 +37644,7 @@ function chunkLabels(labels, reservedOps = 0) {
37355
37644
  for (let i = 0; i < labels.length; i += maxPerChunk) {
37356
37645
  chunks.push(labels.slice(i, i + maxPerChunk));
37357
37646
  }
37358
- debug(MODULE9, `Split ${labels.length} labels into ${chunks.length} chunks (${reservedOps} ops reserved, max ${maxPerChunk} per chunk)`);
37647
+ debug(MODULE10, `Split ${labels.length} labels into ${chunks.length} chunks (${reservedOps} ops reserved, max ${maxPerChunk} per chunk)`);
37359
37648
  return chunks;
37360
37649
  }
37361
37650
  function buildLabelQuery(labels) {
@@ -37388,11 +37677,11 @@ function interleaveArrays(arrays) {
37388
37677
  }
37389
37678
  var SEARCH_CACHE_TTL_MS = 15 * 60 * 1e3;
37390
37679
  async function cachedSearchIssues(octokit, params) {
37391
- const cacheKey = `search:${params.q}:${params.sort}:${params.order}:${params.per_page}`;
37680
+ const cacheKey = versionedCacheKey(`search:${params.q}:${params.sort}:${params.order}:${params.per_page}`);
37392
37681
  const cache = getHttpCache();
37393
37682
  const cached2 = cache.getIfFresh(cacheKey, SEARCH_CACHE_TTL_MS);
37394
37683
  if (cached2) {
37395
- debug(MODULE9, `Search cache hit for query`);
37684
+ debug(MODULE10, `Search cache hit for query`);
37396
37685
  return cached2;
37397
37686
  }
37398
37687
  const tracker = getSearchBudgetTracker();
@@ -37407,7 +37696,7 @@ async function cachedSearchIssues(octokit, params) {
37407
37696
  if (data.items.length > 0) {
37408
37697
  cache.set(cacheKey, "", data);
37409
37698
  } else {
37410
- debug(MODULE9, `Skipping cache for empty search result (possible rate limit artifact)`);
37699
+ debug(MODULE10, `Skipping cache for empty search result (possible rate limit artifact)`);
37411
37700
  }
37412
37701
  return data;
37413
37702
  }
@@ -37455,10 +37744,10 @@ async function fetchIssuesFromMaintainedRepos(octokit, repos, minStars, maxResul
37455
37744
  if (getHttpStatusCode(error48) === 401)
37456
37745
  throw error48;
37457
37746
  if (isRateLimitError(error48)) {
37458
- warn(MODULE9, `Rate limit hit fetching issues from ${repoFullName}:`, errorMessage(error48));
37747
+ warn(MODULE10, `Rate limit hit fetching issues from ${repoFullName}:`, errorMessage(error48));
37459
37748
  break;
37460
37749
  }
37461
- warn(MODULE9, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
37750
+ warn(MODULE10, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
37462
37751
  }
37463
37752
  }
37464
37753
  return items;
@@ -37475,16 +37764,27 @@ async function fetchIssuesFromKnownRepos(octokit, vetter, repos, labels, maxResu
37475
37764
  const repoFullName = repos[i];
37476
37765
  const [owner, repo] = repoFullName.split("/");
37477
37766
  try {
37478
- const response = await octokit.issues.listForRepo({
37479
- owner,
37480
- repo,
37481
- state: "open",
37482
- sort: "created",
37483
- direction: "desc",
37484
- per_page: 5,
37485
- ...labels.length > 0 ? { labels: labels.join(",") } : {}
37486
- });
37487
- const issuesOnly = response.data.filter((item) => !("pull_request" in item) && !item.assignee);
37767
+ const labelFilters = labels.length > 0 ? labels : [void 0];
37768
+ const seenUrls = /* @__PURE__ */ new Set();
37769
+ const rawIssues = [];
37770
+ for (const label of labelFilters) {
37771
+ const response = await octokit.issues.listForRepo({
37772
+ owner,
37773
+ repo,
37774
+ state: "open",
37775
+ sort: "created",
37776
+ direction: "desc",
37777
+ per_page: 5,
37778
+ ...label !== void 0 ? { labels: label } : {}
37779
+ });
37780
+ for (const issue2 of response.data) {
37781
+ if (seenUrls.has(issue2.html_url))
37782
+ continue;
37783
+ seenUrls.add(issue2.html_url);
37784
+ rawIssues.push(issue2);
37785
+ }
37786
+ }
37787
+ const issuesOnly = rawIssues.filter((item) => !("pull_request" in item) && !item.assignee);
37488
37788
  const mapped = issuesOnly.map((issue2) => ({
37489
37789
  html_url: issue2.html_url,
37490
37790
  repository_url: `https://api.github.com/repos/${repoFullName}`,
@@ -37509,13 +37809,13 @@ async function fetchIssuesFromKnownRepos(octokit, vetter, repos, labels, maxResu
37509
37809
  if (isRateLimitError(error48)) {
37510
37810
  rateLimitFailures++;
37511
37811
  }
37512
- warn(MODULE9, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
37812
+ warn(MODULE10, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
37513
37813
  }
37514
37814
  }
37515
37815
  const allReposFailed = failedRepos === repos.length && repos.length > 0;
37516
37816
  const rateLimitHit = rateLimitFailures > 0;
37517
37817
  if (allReposFailed) {
37518
- warn(MODULE9, `All ${repos.length} repo(s) failed for ${priority} phase. This may indicate a systemic issue (rate limit, auth, network).`);
37818
+ warn(MODULE10, `All ${repos.length} repo(s) failed for ${priority} phase. This may indicate a systemic issue (rate limit, auth, network).`);
37519
37819
  }
37520
37820
  return { candidates, allReposFailed, rateLimitHit };
37521
37821
  }
@@ -37572,7 +37872,7 @@ async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets,
37572
37872
  const spamRepos = detectLabelFarmingRepos(items);
37573
37873
  if (spamRepos.size > 0) {
37574
37874
  const spamCount = items.filter((i) => spamRepos.has(extractRepoFromUrl(i.repository_url) ?? "")).length;
37575
- debug(MODULE9, `[SPAM_FILTER] Filtered ${spamCount} issues from ${spamRepos.size} label-farming repos: ${[...spamRepos].join(", ")}`);
37875
+ debug(MODULE10, `[SPAM_FILTER] Filtered ${spamCount} issues from ${spamRepos.size} label-farming repos: ${[...spamRepos].join(", ")}`);
37576
37876
  }
37577
37877
  const itemsToVet = filterIssues(items).filter((item) => {
37578
37878
  const repoFullName = extractRepoFromUrl(item.repository_url);
@@ -37583,7 +37883,7 @@ async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets,
37583
37883
  return excludedRepoSets.every((s) => !s.has(repoFullName));
37584
37884
  }).slice(0, remainingNeeded * 2);
37585
37885
  if (itemsToVet.length === 0) {
37586
- debug(MODULE9, `[${phaseLabel}] All ${items.length} items filtered before vetting`);
37886
+ debug(MODULE10, `[${phaseLabel}] All ${items.length} items filtered before vetting`);
37587
37887
  return { candidates: [], allVetFailed: false, rateLimitHit: false };
37588
37888
  }
37589
37889
  const { candidates: results, allFailed: allVetFailed, rateLimitHit } = await vetter.vetIssuesParallel(itemsToVet.map((i) => i.html_url), remainingNeeded, "normal");
@@ -37595,7 +37895,7 @@ async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets,
37595
37895
  });
37596
37896
  const starFilteredCount = results.length - starFiltered.length;
37597
37897
  if (starFilteredCount > 0) {
37598
- debug(MODULE9, `[STAR_FILTER] Filtered ${starFilteredCount} ${phaseLabel} candidates below ${minStars} stars`);
37898
+ debug(MODULE10, `[STAR_FILTER] Filtered ${starFilteredCount} ${phaseLabel} candidates below ${minStars} stars`);
37599
37899
  }
37600
37900
  return { candidates: starFiltered, allVetFailed, rateLimitHit };
37601
37901
  }
@@ -37603,32 +37903,72 @@ async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets,
37603
37903
  // ../core/dist/core/personalization.js
37604
37904
  var REPO_BOOST = 20;
37605
37905
  var LANGUAGE_BOOST = 10;
37906
+ function boostScoreOf(candidate) {
37907
+ return candidate.personalization?.kind === "boosted" ? candidate.personalization.score : 0;
37908
+ }
37606
37909
  function annotateBoost(candidates, preferLanguages, preferRepos) {
37607
37910
  const langSet = new Set((preferLanguages ?? []).map((l) => l.trim().toLowerCase()).filter(Boolean));
37608
- const repoSet = new Set((preferRepos ?? []).map((r) => r.trim()).filter(Boolean));
37911
+ const repoSet = new Set((preferRepos ?? []).map((r) => r.trim().toLowerCase()).filter(Boolean));
37609
37912
  if (langSet.size === 0 && repoSet.size === 0)
37610
- return;
37611
- for (const c of candidates) {
37913
+ return candidates;
37914
+ return candidates.map((c) => {
37612
37915
  let score = 0;
37613
37916
  const reasons = [];
37614
- if (repoSet.size > 0 && repoSet.has(c.issue.repo)) {
37917
+ if (repoSet.size > 0 && repoSet.has(c.issue.repo.toLowerCase())) {
37615
37918
  score += REPO_BOOST;
37616
37919
  reasons.push(`repo affinity: ${c.issue.repo}`);
37617
37920
  }
37618
- const lang = c.projectHealth.language;
37921
+ const lang = c.projectHealth.checkFailed ? null : c.projectHealth.language;
37619
37922
  if (langSet.size > 0 && lang && langSet.has(lang.toLowerCase())) {
37620
37923
  score += LANGUAGE_BOOST;
37621
37924
  reasons.push(`language match: ${lang}`);
37622
37925
  }
37623
- if (score > 0) {
37624
- c.boostScore = score;
37625
- c.boostReasons = reasons;
37626
- }
37926
+ if (score === 0)
37927
+ return c;
37928
+ return { ...c, personalization: { kind: "boosted", score, reasons } };
37929
+ });
37930
+ }
37931
+ function applyDiversityRatio(candidates, maxResults, diversityRatio) {
37932
+ if (maxResults <= 0)
37933
+ return [];
37934
+ const ratio = Math.max(0, Math.min(1, diversityRatio));
37935
+ if (ratio === 0)
37936
+ return candidates.slice(0, maxResults);
37937
+ const diversityReserve = Math.min(Math.floor(maxResults * ratio), maxResults);
37938
+ if (diversityReserve === 0)
37939
+ return candidates.slice(0, maxResults);
37940
+ const mainBudget = maxResults - diversityReserve;
37941
+ const picks = [];
37942
+ const seen = /* @__PURE__ */ new Set();
37943
+ for (const c of candidates) {
37944
+ if (picks.length >= mainBudget)
37945
+ break;
37946
+ picks.push(c);
37947
+ seen.add(c.issue.url);
37948
+ }
37949
+ for (const c of candidates) {
37950
+ if (picks.length >= maxResults)
37951
+ break;
37952
+ if (seen.has(c.issue.url))
37953
+ continue;
37954
+ if (boostScoreOf(c) > 0)
37955
+ continue;
37956
+ picks.push({ ...c, personalization: { kind: "diversity" } });
37957
+ seen.add(c.issue.url);
37627
37958
  }
37959
+ for (const c of candidates) {
37960
+ if (picks.length >= maxResults)
37961
+ break;
37962
+ if (seen.has(c.issue.url))
37963
+ continue;
37964
+ picks.push(c);
37965
+ seen.add(c.issue.url);
37966
+ }
37967
+ return picks;
37628
37968
  }
37629
37969
 
37630
37970
  // ../core/dist/core/issue-discovery.js
37631
- var MODULE10 = "issue-discovery";
37971
+ var MODULE11 = "issue-discovery";
37632
37972
  var LOW_BUDGET_THRESHOLD = 20;
37633
37973
  var CRITICAL_BUDGET_THRESHOLD = 10;
37634
37974
  function buildIssueFilter(config2) {
@@ -37637,16 +37977,17 @@ function buildIssueFilter(config2) {
37637
37977
  const repoFullName = extractRepoFromUrl(item.repository_url);
37638
37978
  if (!repoFullName)
37639
37979
  return false;
37640
- if (config2.excludedRepos.has(repoFullName))
37980
+ const repoLower = repoFullName.toLowerCase();
37981
+ if (config2.excludedRepos.has(repoLower))
37641
37982
  return false;
37642
37983
  if (config2.excludeOrgs.size > 0) {
37643
- const orgName = repoFullName.split("/")[0]?.toLowerCase();
37984
+ const orgName = repoLower.split("/")[0];
37644
37985
  if (orgName && config2.excludeOrgs.has(orgName))
37645
37986
  return false;
37646
37987
  }
37647
- if (config2.aiBlocklisted.has(repoFullName))
37988
+ if (config2.aiBlocklisted.has(repoLower))
37648
37989
  return false;
37649
- if (config2.lowScoringRepos.has(repoFullName))
37990
+ if (config2.lowScoringRepos.has(repoLower))
37650
37991
  return false;
37651
37992
  if (config2.skippedUrls.has(item.html_url))
37652
37993
  return false;
@@ -37661,9 +38002,9 @@ function buildIssueFilter(config2) {
37661
38002
  };
37662
38003
  }
37663
38004
  async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
37664
- info(MODULE10, `Phase 0: Searching issues in ${repos.length} merged-PR repos (no label filter)...`);
38005
+ info(MODULE11, `Phase 0: Searching issues in ${repos.length} merged-PR repos (no label filter)...`);
37665
38006
  const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, repos, [], maxResults, "merged_pr", filterIssues);
37666
- info(MODULE10, `Found ${candidates.length} candidates from merged-PR repos`);
38007
+ info(MODULE11, `Found ${candidates.length} candidates from merged-PR repos`);
37667
38008
  return {
37668
38009
  candidates,
37669
38010
  error: allReposFailed ? "All merged-PR repo fetches failed" : null,
@@ -37671,11 +38012,11 @@ async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
37671
38012
  };
37672
38013
  }
37673
38014
  async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssues) {
37674
- info(MODULE10, `Phase 1: Searching issues in ${repos.length} starred repos...`);
38015
+ info(MODULE11, `Phase 1: Searching issues in ${repos.length} starred repos...`);
37675
38016
  const phase1Labels = labels.slice(0, 3);
37676
38017
  const reposToSearch = repos.slice(0, 10);
37677
38018
  const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, reposToSearch, phase1Labels, maxResults, "starred", filterIssues);
37678
- info(MODULE10, `Found ${candidates.length} candidates from starred repos`);
38019
+ info(MODULE11, `Found ${candidates.length} candidates from starred repos`);
37679
38020
  return {
37680
38021
  candidates,
37681
38022
  error: allReposFailed ? "All starred repo fetches failed" : null,
@@ -37683,14 +38024,14 @@ async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssue
37683
38024
  };
37684
38025
  }
37685
38026
  async function runPhase2(octokit, vetter, scopes, labels, configLabels, languages, isAnyLanguage, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
37686
- info(MODULE10, "Phase 2: General issue search...");
38027
+ info(MODULE11, "Phase 2: General issue search...");
37687
38028
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
37688
38029
  const tierLabelGroups = [];
37689
38030
  if (scopes && scopes.length > 1) {
37690
38031
  for (const scope of scopes) {
37691
38032
  const scopeLabels = SCOPE_LABELS[scope] ?? [];
37692
38033
  if (scopeLabels.length === 0) {
37693
- warn(MODULE10, `Scope "${scope}" has no labels, skipping tier`);
38034
+ warn(MODULE11, `Scope "${scope}" has no labels, skipping tier`);
37694
38035
  continue;
37695
38036
  }
37696
38037
  tierLabelGroups.push({ tier: scope, tierLabels: scopeLabels });
@@ -37710,7 +38051,7 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37710
38051
  for (const { tier, tierLabels } of tierLabelGroups) {
37711
38052
  try {
37712
38053
  const allItems = await searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, tierLabels, (langQ) => `is:issue is:open ${langQ} no:assignee`.replace(/ +/g, " ").trim(), budgetPerTier * 3);
37713
- info(MODULE10, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
38054
+ info(MODULE11, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
37714
38055
  const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, allItems, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
37715
38056
  tierResults.push(tierCandidates);
37716
38057
  for (const c of tierCandidates)
@@ -37721,7 +38062,7 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37721
38062
  if (vetRateLimitHit) {
37722
38063
  rateLimitHit = true;
37723
38064
  }
37724
- info(MODULE10, `Found ${tierCandidates.length} candidates from ${tier} tier`);
38065
+ info(MODULE11, `Found ${tierCandidates.length} candidates from ${tier} tier`);
37725
38066
  } catch (err) {
37726
38067
  if (getHttpStatusCode(err) === 401)
37727
38068
  throw err;
@@ -37730,13 +38071,13 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37730
38071
  if (isRateLimitError(err)) {
37731
38072
  rateLimitHit = true;
37732
38073
  }
37733
- warn(MODULE10, `Error in ${tier} tier search: ${errMsg}`);
38074
+ warn(MODULE11, `Error in ${tier} tier search: ${errMsg}`);
37734
38075
  tierResults.push([]);
37735
38076
  }
37736
38077
  }
37737
38078
  const interleaved = interleaveArrays(tierResults);
37738
38079
  if (interleaved.length === 0 && error48) {
37739
- warn(MODULE10, `All ${tierLabelGroups.length} scope tiers failed in Phase 2: ${error48}`);
38080
+ warn(MODULE11, `All ${tierLabelGroups.length} scope tiers failed in Phase 2: ${error48}`);
37740
38081
  }
37741
38082
  return {
37742
38083
  candidates: interleaved.slice(0, maxResults),
@@ -37745,17 +38086,17 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37745
38086
  };
37746
38087
  }
37747
38088
  async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, starredRepos, existingCandidates, filterIssues) {
37748
- info(MODULE10, "Phase 3: Searching actively maintained repos...");
38089
+ info(MODULE11, "Phase 3: Searching actively maintained repos...");
37749
38090
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
37750
38091
  const eligibleStarred = starredRepos.filter((r) => !phase0RepoSet.has(r) && !seenRepos.has(r));
37751
38092
  if (eligibleStarred.length > 0) {
37752
- info(MODULE10, `Phase 3: Checking ${eligibleStarred.length} starred repos via REST API...`);
38093
+ info(MODULE11, `Phase 3: Checking ${eligibleStarred.length} starred repos via REST API...`);
37753
38094
  const restItems = await fetchIssuesFromMaintainedRepos(octokit, eligibleStarred.slice(0, 15), minStars, maxResults);
37754
38095
  if (restItems.length > 0) {
37755
38096
  try {
37756
38097
  const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, restItems, filterIssues, [phase0RepoSet, seenRepos], maxResults, minStars, "Phase 3 (REST)");
37757
38098
  if (candidates.length > 0) {
37758
- info(MODULE10, `Found ${candidates.length} candidates from maintained-repo REST search`);
38099
+ info(MODULE11, `Found ${candidates.length} candidates from maintained-repo REST search`);
37759
38100
  return {
37760
38101
  candidates,
37761
38102
  error: allVetFailed ? "all vetting failed" : null,
@@ -37765,11 +38106,11 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37765
38106
  } catch (error48) {
37766
38107
  if (getHttpStatusCode(error48) === 401)
37767
38108
  throw error48;
37768
- warn(MODULE10, `Phase 3 REST vetting failed, falling back to Search API:`, errorMessage(error48));
38109
+ warn(MODULE11, `Phase 3 REST vetting failed, falling back to Search API:`, errorMessage(error48));
37769
38110
  }
37770
38111
  }
37771
38112
  }
37772
- info(MODULE10, "Phase 3: Falling back to Search API...");
38113
+ info(MODULE11, "Phase 3: Falling back to Search API...");
37773
38114
  const thirtyDaysAgo = /* @__PURE__ */ new Date();
37774
38115
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
37775
38116
  const pushedSince = thirtyDaysAgo.toISOString().split("T")[0];
@@ -37783,9 +38124,9 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37783
38124
  order: "desc",
37784
38125
  per_page: maxResults * 3
37785
38126
  });
37786
- info(MODULE10, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
38127
+ info(MODULE11, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
37787
38128
  const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], maxResults, minStars, "Phase 3");
37788
- info(MODULE10, `Found ${candidates.length} candidates from maintained-repo search`);
38129
+ info(MODULE11, `Found ${candidates.length} candidates from maintained-repo search`);
37789
38130
  return {
37790
38131
  candidates,
37791
38132
  error: allVetFailed ? "all vetting failed" : null,
@@ -37795,7 +38136,7 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37795
38136
  if (getHttpStatusCode(error48) === 401)
37796
38137
  throw error48;
37797
38138
  const errMsg = errorMessage(error48);
37798
- warn(MODULE10, `Error in maintained-repo search: ${errMsg}`);
38139
+ warn(MODULE11, `Error in maintained-repo search: ${errMsg}`);
37799
38140
  return {
37800
38141
  candidates: [],
37801
38142
  error: errMsg,
@@ -37861,9 +38202,10 @@ var IssueDiscovery = class {
37861
38202
  const labels = options.labels || (scopes ? buildEffectiveLabels(scopes, config2.labels) : config2.labels);
37862
38203
  const maxResults = options.maxResults || 10;
37863
38204
  const minStars = config2.minStars ?? 50;
37864
- const interPhaseDelay = config2.interPhaseDelayMs ?? 3e4;
38205
+ const interPhaseDelay = options.interPhaseDelayMs ?? config2.interPhaseDelayMs ?? 3e4;
37865
38206
  const ALL_STRATEGIES = CONCRETE_STRATEGIES;
37866
- const rawStrategies = options.strategies ?? config2.defaultStrategy ?? ["all"];
38207
+ const pickStrategies = (...candidates) => candidates.find((c) => c && c.length > 0) ?? ["all"];
38208
+ const rawStrategies = pickStrategies(options.strategies, config2.defaultStrategy);
37867
38209
  const enabledStrategies = new Set(rawStrategies.includes("all") ? ALL_STRATEGIES : rawStrategies);
37868
38210
  const strategiesUsed = [];
37869
38211
  const allCandidates = [];
@@ -37879,18 +38221,18 @@ var IssueDiscovery = class {
37879
38221
  if (rateLimit.remaining < 5) {
37880
38222
  const resetTime = new Date(rateLimit.resetAt).toLocaleTimeString("en-US", { hour12: false });
37881
38223
  this.rateLimitWarning = `GitHub search API quota low (${rateLimit.remaining}/${rateLimit.limit} remaining, resets at ${resetTime}). Search may be slow.`;
37882
- warn(MODULE10, this.rateLimitWarning);
38224
+ warn(MODULE11, this.rateLimitWarning);
37883
38225
  }
37884
38226
  if (searchBudget < CRITICAL_BUDGET_THRESHOLD) {
37885
- info(MODULE10, `Search budget critical (${searchBudget} remaining) \u2014 running only Phase 0`);
38227
+ info(MODULE11, `Search budget critical (${searchBudget} remaining) \u2014 running only Phase 0`);
37886
38228
  } else if (searchBudget < LOW_BUDGET_THRESHOLD) {
37887
- info(MODULE10, `Search budget low (${searchBudget} remaining) \u2014 skipping heavy phases (2, 3)`);
38229
+ info(MODULE11, `Search budget low (${searchBudget} remaining) \u2014 skipping heavy phases (2, 3)`);
37888
38230
  }
37889
38231
  } catch (error48) {
37890
38232
  if (getHttpStatusCode(error48) === 401)
37891
38233
  throw error48;
37892
38234
  tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 6e4).toISOString());
37893
- warn(MODULE10, "Could not check rate limit \u2014 using conservative budget, skipping heavy phases:", errorMessage(error48));
38235
+ warn(MODULE11, "Could not check rate limit \u2014 using conservative budget, skipping heavy phases:", errorMessage(error48));
37894
38236
  }
37895
38237
  if (searchBudget <= 0) {
37896
38238
  this.rateLimitWarning = "GitHub search API quota exhausted. Try again after the rate limit resets.";
@@ -37900,15 +38242,15 @@ var IssueDiscovery = class {
37900
38242
  const openPRRepos = this.stateReader.getReposWithOpenPRs();
37901
38243
  const starredRepos = this.getStarredRepos();
37902
38244
  const starredRepoSet = new Set(starredRepos);
37903
- const lowScoringRepos = new Set(this.deriveLowScoringRepos(config2.minRepoScoreThreshold));
38245
+ const lowScoringRepos = new Set(this.deriveLowScoringRepos(config2.minRepoScoreThreshold).map((r) => r.toLowerCase()));
37904
38246
  const isAnyLanguage = languages.some((l) => l.toLowerCase() === "any");
37905
38247
  const langQuery = isAnyLanguage ? "" : languages.map((l) => `language:${l}`).join(" ");
37906
- const aiBlocklisted = new Set(config2.aiPolicyBlocklist);
38248
+ const aiBlocklisted = new Set(config2.aiPolicyBlocklist.map((r) => r.toLowerCase()));
37907
38249
  if (aiBlocklisted.size > 0) {
37908
- debug(MODULE10, `[AI_POLICY_FILTER] Filtering issues from ${aiBlocklisted.size} blocklisted repo(s): ${[...aiBlocklisted].join(", ")}`);
38250
+ debug(MODULE11, `[AI_POLICY_FILTER] Filtering issues from ${aiBlocklisted.size} blocklisted repo(s): ${[...aiBlocklisted].join(", ")}`);
37909
38251
  }
37910
38252
  const filterIssues = buildIssueFilter({
37911
- excludedRepos: new Set(config2.excludeRepos),
38253
+ excludedRepos: new Set(config2.excludeRepos.map((r) => r.toLowerCase())),
37912
38254
  excludeOrgs: new Set((config2.excludeOrgs ?? []).map((o) => o.toLowerCase())),
37913
38255
  aiBlocklisted,
37914
38256
  lowScoringRepos,
@@ -37941,7 +38283,7 @@ var IssueDiscovery = class {
37941
38283
  }
37942
38284
  if (allCandidates.length < maxResults && starredRepos.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD && enabledStrategies.has("starred")) {
37943
38285
  if (interPhaseDelay > 0) {
37944
- info(MODULE10, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
38286
+ info(MODULE11, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
37945
38287
  await sleep(interPhaseDelay);
37946
38288
  }
37947
38289
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
@@ -37953,25 +38295,26 @@ var IssueDiscovery = class {
37953
38295
  phaseErrors["1"] = result.error;
37954
38296
  if (result.rateLimitHit)
37955
38297
  rateLimitHitDuringSearch = true;
38298
+ strategiesUsed.push("starred");
37956
38299
  }
37957
38300
  }
37958
- strategiesUsed.push("starred");
37959
38301
  }
37960
- const broadDelay = config2.broadPhaseDelayMs ?? 9e4;
37961
- const skipThreshold = config2.skipBroadWhenSufficientResults ?? 15;
38302
+ const broadDelay = options.broadPhaseDelayMs ?? config2.broadPhaseDelayMs ?? 9e4;
38303
+ const configuredSkipThreshold = config2.skipBroadWhenSufficientResults ?? 8;
38304
+ const skipThreshold = configuredSkipThreshold > 0 ? Math.min(configuredSkipThreshold, maxResults - 1) : 0;
37962
38305
  if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD && enabledStrategies.has("broad")) {
37963
38306
  if (skipThreshold > 0 && allCandidates.length >= skipThreshold) {
37964
- info(MODULE10, `Skipping broad search: already found ${allCandidates.length} candidates (threshold: ${skipThreshold})`);
38307
+ info(MODULE11, `Skipping broad search: already found ${allCandidates.length} candidates (threshold: ${skipThreshold})`);
37965
38308
  } else {
37966
38309
  if (interPhaseDelay > 0) {
37967
- info(MODULE10, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
38310
+ info(MODULE11, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
37968
38311
  await sleep(interPhaseDelay);
37969
38312
  }
37970
38313
  if (allCandidates.length > 0 && broadDelay > 0) {
37971
- info(MODULE10, `Waiting ${(broadDelay / 1e3).toFixed(0)}s for rate limit cooldown before broad search...`);
38314
+ info(MODULE11, `Waiting ${(broadDelay / 1e3).toFixed(0)}s for rate limit cooldown before broad search...`);
37972
38315
  await sleep(broadDelay);
37973
38316
  } else if (allCandidates.length === 0) {
37974
- info(MODULE10, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
38317
+ info(MODULE11, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
37975
38318
  }
37976
38319
  const remaining = maxResults - allCandidates.length;
37977
38320
  const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config2.labels, languages, isAnyLanguage, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
@@ -37979,12 +38322,12 @@ var IssueDiscovery = class {
37979
38322
  phaseErrors["2"] = result.error;
37980
38323
  if (result.rateLimitHit)
37981
38324
  rateLimitHitDuringSearch = true;
38325
+ strategiesUsed.push("broad");
37982
38326
  }
37983
- strategiesUsed.push("broad");
37984
38327
  }
37985
38328
  if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD && enabledStrategies.has("maintained")) {
37986
38329
  if (interPhaseDelay > 0) {
37987
- info(MODULE10, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
38330
+ info(MODULE11, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
37988
38331
  await sleep(interPhaseDelay);
37989
38332
  }
37990
38333
  const remaining = maxResults - allCandidates.length;
@@ -38019,8 +38362,8 @@ var IssueDiscovery = class {
38019
38362
  if (rateLimitHitDuringSearch || phasesSkippedForBudget) {
38020
38363
  this.rateLimitWarning = `Search results may be incomplete: GitHub API rate limits were hit during search.${budgetNote} Found ${allCandidates.length} candidate${allCandidates.length === 1 ? "" : "s"} but some search phases were limited. Try again after the rate limit resets for complete results.`;
38021
38364
  }
38022
- annotateBoost(allCandidates, options.preferLanguages, options.preferRepos);
38023
- allCandidates.sort((a, b) => {
38365
+ const ranked = annotateBoost(allCandidates, options.preferLanguages, options.preferRepos);
38366
+ ranked.sort((a, b) => {
38024
38367
  const priorityOrder = {
38025
38368
  merged_pr: 0,
38026
38369
  starred: 1,
@@ -38033,14 +38376,15 @@ var IssueDiscovery = class {
38033
38376
  const recDiff = recommendationOrder[a.recommendation] - recommendationOrder[b.recommendation];
38034
38377
  if (recDiff !== 0)
38035
38378
  return recDiff;
38036
- const boostDiff = (b.boostScore ?? 0) - (a.boostScore ?? 0);
38379
+ const boostDiff = boostScoreOf(b) - boostScoreOf(a);
38037
38380
  if (boostDiff !== 0)
38038
38381
  return boostDiff;
38039
38382
  return b.viabilityScore - a.viabilityScore;
38040
38383
  });
38041
- const capped = applyPerRepoCap(allCandidates, 2);
38042
- info(MODULE10, `Search complete: ${tracker.getTotalCalls()} Search API calls used, ${capped.length} candidates returned`);
38043
- return { candidates: capped.slice(0, maxResults), strategiesUsed };
38384
+ const capped = applyPerRepoCap(ranked, 2);
38385
+ const finalPicks = applyDiversityRatio(capped, maxResults, options.diversityRatio ?? 0);
38386
+ info(MODULE11, `Search complete: ${tracker.getTotalCalls()} Search API calls used, ${finalPicks.length} candidates returned`);
38387
+ return { candidates: finalPicks, strategiesUsed };
38044
38388
  }
38045
38389
  /**
38046
38390
  * Vet a specific issue for claimability and project health.
@@ -38072,7 +38416,7 @@ var IssueDiscovery = class {
38072
38416
  };
38073
38417
 
38074
38418
  // ../core/dist/core/roadmap.js
38075
- var MODULE11 = "roadmap";
38419
+ var MODULE12 = "roadmap";
38076
38420
  var CACHE_TTL_MS2 = 60 * 60 * 1e3;
38077
38421
  var ROADMAP_PATHS = [
38078
38422
  "ROADMAP.md",
@@ -38126,6 +38470,19 @@ async function fetchRoadmapIssueRefs(octokit, owner, repo) {
38126
38470
  if (cached2 && Date.now() - cached2.fetchedAt < CACHE_TTL_MS2) {
38127
38471
  return cached2.refs;
38128
38472
  }
38473
+ const inflight = roadmapInflight.get(cacheKey);
38474
+ if (inflight)
38475
+ return inflight;
38476
+ const promise2 = fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey);
38477
+ roadmapInflight.set(cacheKey, promise2);
38478
+ try {
38479
+ return await promise2;
38480
+ } finally {
38481
+ roadmapInflight.delete(cacheKey);
38482
+ }
38483
+ }
38484
+ var roadmapInflight = /* @__PURE__ */ new Map();
38485
+ async function fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey) {
38129
38486
  for (const path5 of ROADMAP_PATHS) {
38130
38487
  try {
38131
38488
  const { data } = await octokit.repos.getContent({ owner, repo, path: path5 });
@@ -38137,12 +38494,11 @@ async function fetchRoadmapIssueRefs(octokit, owner, repo) {
38137
38494
  pruneCache2();
38138
38495
  return refs;
38139
38496
  } catch (err) {
38140
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38141
- throw err;
38497
+ rethrowIfFatal(err);
38142
38498
  const status = getHttpStatusCode(err);
38143
38499
  if (status === 404)
38144
38500
  continue;
38145
- warn(MODULE11, `Unexpected error fetching ${path5} from ${owner}/${repo}: ${errorMessage(err)}`);
38501
+ warn(MODULE12, `Unexpected error fetching ${path5} from ${owner}/${repo}: ${errorMessage(err)}`);
38146
38502
  }
38147
38503
  }
38148
38504
  const empty = /* @__PURE__ */ new Set();
@@ -38152,7 +38508,7 @@ async function fetchRoadmapIssueRefs(octokit, owner, repo) {
38152
38508
  }
38153
38509
 
38154
38510
  // ../core/dist/core/feature-discovery.js
38155
- var MODULE12 = "feature-discovery";
38511
+ var MODULE13 = "feature-discovery";
38156
38512
  var INTER_REPO_DELAY_MS = 2e3;
38157
38513
  var MIN_VIABILITY_SCORE = 40;
38158
38514
  var ANCHOR_THRESHOLD = 3;
@@ -38244,6 +38600,37 @@ function isFeatureIssue(item) {
38244
38600
  return false;
38245
38601
  return labels.some((l) => FEATURE_LABELS.includes(l));
38246
38602
  }
38603
+ async function vetAndClassify(item, vetter, roadmapRefs) {
38604
+ const labels = extractLabels(item);
38605
+ const hasMilestone = !!item.milestone;
38606
+ const reactions = item.reactions?.total_count ?? 0;
38607
+ const comments = item.comments ?? 0;
38608
+ const wontfixNoContributor = item.created_at ? detectWontfixNoContributor({ labels, createdAt: item.created_at }) : false;
38609
+ const useRoadmap = roadmapRefs !== void 0;
38610
+ const onRoadmap = useRoadmap && typeof item.number === "number" && roadmapRefs.has(item.number);
38611
+ let candidate;
38612
+ try {
38613
+ candidate = await vetter.vetIssue(item.html_url, {
38614
+ featureSignals: {
38615
+ reactions,
38616
+ comments,
38617
+ hasMilestone,
38618
+ wontfixNoContributor,
38619
+ ...useRoadmap ? { onRoadmap } : {}
38620
+ }
38621
+ });
38622
+ } catch (err) {
38623
+ rethrowIfFatal(err);
38624
+ warn(MODULE13, `vet failed for ${item.html_url}: ${errorMessage(err)}`);
38625
+ return null;
38626
+ }
38627
+ const horizon = classifyHorizon({
38628
+ hasMilestone,
38629
+ labels,
38630
+ ...useRoadmap ? { isOnRoadmap: onRoadmap } : {}
38631
+ });
38632
+ return { ...candidate, horizon };
38633
+ }
38247
38634
  async function discoverFeatures(opts) {
38248
38635
  const anchorRepos = resolveAnchorRepos(opts.repoScores, opts.anchorThreshold);
38249
38636
  if (anchorRepos.length === 0) {
@@ -38276,42 +38663,15 @@ async function discoverFeatures(opts) {
38276
38663
  response = listResp;
38277
38664
  roadmapRefs = refs;
38278
38665
  } catch (err) {
38279
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38280
- throw err;
38281
- warn(MODULE12, `failed to list issues for ${anchorRepos[i]}: ${errorMessage(err)}`);
38666
+ rethrowIfFatal(err);
38667
+ warn(MODULE13, `failed to list issues for ${anchorRepos[i]}: ${errorMessage(err)}`);
38282
38668
  continue;
38283
38669
  }
38284
38670
  const items = response.data.filter((it) => !it.pull_request && !it.assignee && isFeatureIssue(it));
38285
38671
  for (const item of items) {
38286
- const labels = extractLabels(item);
38287
- const hasMilestone = !!item.milestone;
38288
- const reactions = item.reactions?.total_count ?? 0;
38289
- const comments = item.comments ?? 0;
38290
- const wontfixNoContributor = item.created_at ? detectWontfixNoContributor({ labels, createdAt: item.created_at }) : false;
38291
- const onRoadmap = typeof item.number === "number" && roadmapRefs.has(item.number);
38292
- let candidate;
38293
- try {
38294
- candidate = await opts.vetter.vetIssue(item.html_url, {
38295
- featureSignals: {
38296
- reactions,
38297
- comments,
38298
- hasMilestone,
38299
- wontfixNoContributor,
38300
- onRoadmap
38301
- }
38302
- });
38303
- } catch (err) {
38304
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38305
- throw err;
38306
- warn(MODULE12, `vet failed for ${item.html_url}: ${errorMessage(err)}`);
38307
- continue;
38308
- }
38309
- const horizon = classifyHorizon({
38310
- hasMilestone,
38311
- labels,
38312
- isOnRoadmap: onRoadmap
38313
- });
38314
- candidates.push({ ...candidate, horizon });
38672
+ const candidate = await vetAndClassify(item, opts.vetter, roadmapRefs);
38673
+ if (candidate)
38674
+ candidates.push(candidate);
38315
38675
  }
38316
38676
  }
38317
38677
  const passing = candidates.filter((c) => c.viabilityScore >= MIN_VIABILITY_SCORE);
@@ -38332,10 +38692,8 @@ function buildBroadFeatureSearchQuery(opts) {
38332
38692
  for (const excl of FEATURE_EXCLUSION_LABELS) {
38333
38693
  parts.push(`-label:"${excl}"`);
38334
38694
  }
38335
- const languages = (opts.languages ?? []).filter((l) => l && l.toLowerCase() !== "any");
38336
- if (languages.length > 0) {
38337
- const langClause = languages.map((l) => `language:${l}`).join(" OR ");
38338
- parts.push(`(${langClause})`);
38695
+ if (opts.language) {
38696
+ parts.push(`language:${opts.language}`);
38339
38697
  }
38340
38698
  for (const repo of opts.excludeRepos ?? []) {
38341
38699
  parts.push(`-repo:${repo}`);
@@ -38345,8 +38703,19 @@ function buildBroadFeatureSearchQuery(opts) {
38345
38703
  }
38346
38704
  return parts.join(" ");
38347
38705
  }
38706
+ function buildBroadFeatureSearchQueries(opts) {
38707
+ const base = {
38708
+ excludeRepos: opts.excludeRepos,
38709
+ excludeOrgs: opts.excludeOrgs
38710
+ };
38711
+ const languages = (opts.languages ?? []).filter((l) => l && l.toLowerCase() !== "any");
38712
+ if (languages.length === 0) {
38713
+ return [buildBroadFeatureSearchQuery(base)];
38714
+ }
38715
+ return languages.map((language) => buildBroadFeatureSearchQuery({ ...base, language }));
38716
+ }
38348
38717
  async function discoverFeaturesBroad(opts) {
38349
- const query = buildBroadFeatureSearchQuery({
38718
+ const queries = buildBroadFeatureSearchQueries({
38350
38719
  languages: opts.languages,
38351
38720
  excludeRepos: opts.excludeRepos,
38352
38721
  excludeOrgs: opts.excludeOrgs
@@ -38354,17 +38723,26 @@ async function discoverFeaturesBroad(opts) {
38354
38723
  const maxToVet = opts.maxToVet ?? DEFAULT_BROAD_MAX_TO_VET;
38355
38724
  let items;
38356
38725
  try {
38357
- const response = await opts.octokit.search.issuesAndPullRequests({
38358
- q: query,
38359
- sort: "interactions",
38360
- order: "desc",
38361
- per_page: maxToVet
38362
- });
38363
- items = response.data.items.filter((it) => !it.pull_request && !it.assignee && isFeatureIssue(it));
38726
+ const merged = [];
38727
+ const seenUrls = /* @__PURE__ */ new Set();
38728
+ for (const query of queries) {
38729
+ const data = await cachedSearchIssues(opts.octokit, {
38730
+ q: query,
38731
+ sort: "interactions",
38732
+ order: "desc",
38733
+ per_page: maxToVet
38734
+ });
38735
+ for (const raw of data.items) {
38736
+ if (seenUrls.has(raw.html_url))
38737
+ continue;
38738
+ seenUrls.add(raw.html_url);
38739
+ merged.push(raw);
38740
+ }
38741
+ }
38742
+ items = merged.filter((it) => !it.pull_request && !it.assignee && isFeatureIssue(it)).slice(0, maxToVet);
38364
38743
  } catch (err) {
38365
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38366
- throw err;
38367
- warn(MODULE12, `broad feature search failed: ${errorMessage(err)}`);
38744
+ rethrowIfFatal(err);
38745
+ warn(MODULE13, `broad feature search failed: ${errorMessage(err)}`);
38368
38746
  return {
38369
38747
  quickWins: [],
38370
38748
  biggerBets: [],
@@ -38374,32 +38752,9 @@ async function discoverFeaturesBroad(opts) {
38374
38752
  }
38375
38753
  const candidates = [];
38376
38754
  for (const item of items) {
38377
- const labels = extractLabels(item);
38378
- const hasMilestone = !!item.milestone;
38379
- const reactions = item.reactions?.total_count ?? 0;
38380
- const comments = item.comments ?? 0;
38381
- const wontfixNoContributor = item.created_at ? detectWontfixNoContributor({ labels, createdAt: item.created_at }) : false;
38382
- let candidate;
38383
- try {
38384
- candidate = await opts.vetter.vetIssue(item.html_url, {
38385
- featureSignals: {
38386
- reactions,
38387
- comments,
38388
- hasMilestone,
38389
- wontfixNoContributor
38390
- // Roadmap scraping is per-repo and would require an extra fetch
38391
- // per unique repo in the broad result set — deliberately skipped
38392
- // here to keep the broad path cheap. Anchor mode keeps the bonus.
38393
- }
38394
- });
38395
- } catch (err) {
38396
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38397
- throw err;
38398
- warn(MODULE12, `vet failed for ${item.html_url}: ${errorMessage(err)}`);
38399
- continue;
38400
- }
38401
- const horizon = classifyHorizon({ hasMilestone, labels });
38402
- candidates.push({ ...candidate, horizon });
38755
+ const candidate = await vetAndClassify(item, opts.vetter);
38756
+ if (candidate)
38757
+ candidates.push(candidate);
38403
38758
  }
38404
38759
  const passing = candidates.filter((c) => c.viabilityScore >= MIN_VIABILITY_SCORE);
38405
38760
  const split = splitByHorizon(passing, opts.count, opts.splitRatio);
@@ -38414,19 +38769,16 @@ async function discoverFeaturesBroad(opts) {
38414
38769
  // ../core/dist/core/gist-state-store.js
38415
38770
  var fs3 = __toESM(require("fs"), 1);
38416
38771
  var path3 = __toESM(require("path"), 1);
38417
- var MODULE13 = "gist-state";
38772
+ var MODULE14 = "gist-state";
38418
38773
  var GIST_DESCRIPTION = "oss-scout-state";
38419
38774
  var GIST_FILENAME = "state.json";
38420
38775
  var GIST_ID_FILE = "gist-id";
38421
38776
  var CACHE_FILE = "state-cache.json";
38422
- var SEARCH_MAX_PAGES = 5;
38777
+ var SEARCH_MAX_PAGES = 10;
38423
38778
  function classifyDegradedReason(err) {
38424
38779
  if (isRateLimitError(err))
38425
38780
  return "rate_limit";
38426
38781
  const status = getHttpStatusCode(err);
38427
- if (status === 403 && errorMessage(err).toLowerCase().includes("abuse detection")) {
38428
- return "rate_limit";
38429
- }
38430
38782
  if (status !== void 0 && status >= 500 && status < 600)
38431
38783
  return "server";
38432
38784
  if (err && typeof err === "object" && "code" in err) {
@@ -38462,7 +38814,7 @@ var GistStateStore = class {
38462
38814
  } catch (err) {
38463
38815
  if (getHttpStatusCode(err) === 401)
38464
38816
  throw err;
38465
- warn(MODULE13, `API bootstrap failed: ${errorMessage(err)}`);
38817
+ warn(MODULE14, `API bootstrap failed: ${errorMessage(err)}`);
38466
38818
  return this.bootstrapFromCache(classifyDegradedReason(err));
38467
38819
  }
38468
38820
  }
@@ -38470,14 +38822,25 @@ var GistStateStore = class {
38470
38822
  * Push state to the gist. Also writes to local cache as fallback.
38471
38823
  */
38472
38824
  async push(state) {
38473
- this.writeCache(state);
38474
38825
  if (!this.gistId) {
38475
- warn(MODULE13, "No gist ID \u2014 cannot push");
38826
+ warn(MODULE14, "No gist ID \u2014 cannot push");
38827
+ this.writeCache(state);
38476
38828
  return false;
38477
38829
  }
38478
- const json2 = JSON.stringify(state, null, 2);
38830
+ let toWrite = state;
38831
+ try {
38832
+ const remote = await this.fetchGistState(this.gistId);
38833
+ if (remote) {
38834
+ toWrite = mergeStates(state, remote);
38835
+ }
38836
+ } catch (err) {
38837
+ rethrowIfFatal(err);
38838
+ warn(MODULE14, `Could not fetch gist before push, writing local snapshot: ${errorMessage(err)}`);
38839
+ }
38840
+ this.writeCache(toWrite);
38841
+ const json2 = JSON.stringify(toWrite, null, 2);
38479
38842
  if (json2.length > 9e5) {
38480
- warn(MODULE13, `State too large for gist (${Math.round(json2.length / 1024)}KB). Consider clearing old results with 'oss-scout results clear'.`);
38843
+ warn(MODULE14, `State too large for gist (${Math.round(json2.length / 1024)}KB). Consider clearing old results with 'oss-scout results clear'.`);
38481
38844
  return false;
38482
38845
  }
38483
38846
  try {
@@ -38487,34 +38850,14 @@ var GistStateStore = class {
38487
38850
  [GIST_FILENAME]: { content: json2 }
38488
38851
  }
38489
38852
  });
38490
- debug(MODULE13, "State pushed to gist");
38853
+ debug(MODULE14, "State pushed to gist");
38491
38854
  return true;
38492
38855
  } catch (err) {
38493
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38494
- throw err;
38495
- warn(MODULE13, `Failed to push: ${errorMessage(err)}`);
38856
+ rethrowIfFatal(err);
38857
+ warn(MODULE14, `Failed to push: ${errorMessage(err)}`);
38496
38858
  return false;
38497
38859
  }
38498
38860
  }
38499
- /**
38500
- * Pull state from the gist and merge with local state.
38501
- */
38502
- async pull() {
38503
- if (!this.gistId)
38504
- return null;
38505
- try {
38506
- const state = await this.fetchGistState(this.gistId);
38507
- if (state) {
38508
- this.writeCache(state);
38509
- }
38510
- return state;
38511
- } catch (err) {
38512
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38513
- throw err;
38514
- warn(MODULE13, `Failed to pull: ${errorMessage(err)}`);
38515
- return null;
38516
- }
38517
- }
38518
38861
  /** Get the current gist ID (if known). */
38519
38862
  getGistId() {
38520
38863
  return this.gistId;
@@ -38523,35 +38866,40 @@ var GistStateStore = class {
38523
38866
  async bootstrapFromApi() {
38524
38867
  const cachedId = this.readCachedGistId();
38525
38868
  if (cachedId) {
38526
- debug(MODULE13, `Trying cached gist ID: ${cachedId}`);
38869
+ debug(MODULE14, `Trying cached gist ID: ${cachedId}`);
38527
38870
  try {
38528
- const state = await this.fetchGistState(cachedId);
38529
- if (state) {
38871
+ const fetched = await this.fetchGistState(cachedId);
38872
+ if (fetched) {
38530
38873
  this.gistId = cachedId;
38874
+ const state = this.mergeCacheInto(fetched);
38531
38875
  this.writeCache(state);
38532
38876
  return { gistId: cachedId, state, created: false };
38533
38877
  }
38534
38878
  } catch (err) {
38535
38879
  if (getHttpStatusCode(err) !== 404)
38536
38880
  throw err;
38537
- debug(MODULE13, `Cached gist gone (404): ${errorMessage(err)}`);
38538
- }
38539
- debug(MODULE13, "Cached gist ID invalid, searching...");
38540
- }
38541
- const foundId = await this.searchForGist();
38542
- if (foundId) {
38543
- debug(MODULE13, `Found gist via search: ${foundId}`);
38544
- this.saveGistId(foundId);
38545
- this.gistId = foundId;
38546
- const state = await this.fetchGistState(foundId);
38547
- if (state) {
38881
+ debug(MODULE14, `Cached gist gone (404): ${errorMessage(err)}`);
38882
+ }
38883
+ debug(MODULE14, "Cached gist ID invalid, searching...");
38884
+ }
38885
+ const search = await this.searchForGist();
38886
+ if (search.id) {
38887
+ debug(MODULE14, `Found gist via search: ${search.id}`);
38888
+ this.saveGistId(search.id);
38889
+ this.gistId = search.id;
38890
+ const fetched = await this.fetchGistState(search.id);
38891
+ if (fetched) {
38892
+ const state = this.mergeCacheInto(fetched);
38548
38893
  this.writeCache(state);
38549
- return { gistId: foundId, state, created: false };
38894
+ return { gistId: search.id, state, created: false };
38550
38895
  }
38551
- warn(MODULE13, `Found existing gist ${foundId} but content failed validation. Using local cache to avoid data loss.`);
38896
+ warn(MODULE14, `Found existing gist ${search.id} but content failed validation. Using local cache to avoid data loss.`);
38552
38897
  return this.bootstrapFromCache("unknown");
38553
38898
  }
38554
- debug(MODULE13, "No existing gist found, creating new one");
38899
+ if (!search.exhaustive) {
38900
+ warn(MODULE14, `Scanned the first ${SEARCH_MAX_PAGES * 100} gists without finding an oss-scout state gist, but the account has more. Creating a new state gist; if one already exists, copy its id into the gist-id file in the oss-scout data directory to avoid a duplicate.`);
38901
+ }
38902
+ debug(MODULE14, "No existing gist found, creating new one");
38555
38903
  const freshState = ScoutStateSchema.parse({ version: 1 });
38556
38904
  const newId = await this.createGist(freshState);
38557
38905
  this.saveGistId(newId);
@@ -38562,7 +38910,7 @@ var GistStateStore = class {
38562
38910
  bootstrapFromCache(reason) {
38563
38911
  const cached2 = this.readCache();
38564
38912
  if (cached2) {
38565
- debug(MODULE13, "Bootstrapped from local cache (degraded mode)");
38913
+ debug(MODULE14, "Bootstrapped from local cache (degraded mode)");
38566
38914
  const cachedId = this.readCachedGistId();
38567
38915
  if (cachedId)
38568
38916
  this.gistId = cachedId;
@@ -38574,7 +38922,7 @@ var GistStateStore = class {
38574
38922
  degradedReason: reason
38575
38923
  };
38576
38924
  }
38577
- debug(MODULE13, "No cache available, using fresh state (degraded mode)");
38925
+ debug(MODULE14, "No cache available, using fresh state (degraded mode)");
38578
38926
  const fresh = ScoutStateSchema.parse({ version: 1 });
38579
38927
  return {
38580
38928
  gistId: "",
@@ -38592,12 +38940,17 @@ var GistStateStore = class {
38592
38940
  return null;
38593
38941
  try {
38594
38942
  const parsed = JSON.parse(file2.content);
38595
- return ScoutStateSchema.parse(parsed);
38943
+ return parseScoutState(parsed);
38596
38944
  } catch (err) {
38597
- warn(MODULE13, `Gist content failed validation: ${errorMessage(err)}`);
38945
+ warn(MODULE14, `Gist content failed validation: ${errorMessage(err)}`);
38598
38946
  return null;
38599
38947
  }
38600
38948
  }
38949
+ /**
38950
+ * Scan the user's gists for the state gist. `exhaustive: false` means the
38951
+ * page cap was hit while pages were still full, so the account may hold
38952
+ * the state gist beyond the scan window.
38953
+ */
38601
38954
  async searchForGist() {
38602
38955
  for (let page = 1; page <= SEARCH_MAX_PAGES; page++) {
38603
38956
  const { data: gists } = await this.octokit.gists.list({
@@ -38605,12 +38958,14 @@ var GistStateStore = class {
38605
38958
  page
38606
38959
  });
38607
38960
  if (gists.length === 0)
38608
- break;
38961
+ return { id: null, exhaustive: true };
38609
38962
  const match = gists.find((g) => g.description === GIST_DESCRIPTION);
38610
38963
  if (match)
38611
- return match.id;
38964
+ return { id: match.id, exhaustive: true };
38965
+ if (gists.length < 100)
38966
+ return { id: null, exhaustive: true };
38612
38967
  }
38613
- return null;
38968
+ return { id: null, exhaustive: false };
38614
38969
  }
38615
38970
  async createGist(state) {
38616
38971
  const { data } = await this.octokit.gists.create({
@@ -38630,7 +38985,7 @@ var GistStateStore = class {
38630
38985
  } catch (err) {
38631
38986
  const code = err?.code;
38632
38987
  if (code !== "ENOENT") {
38633
- warn(MODULE13, `Failed to read cached gist ID: ${errorMessage(err)}`);
38988
+ warn(MODULE14, `Failed to read cached gist ID: ${errorMessage(err)}`);
38634
38989
  }
38635
38990
  return null;
38636
38991
  }
@@ -38638,14 +38993,25 @@ var GistStateStore = class {
38638
38993
  saveGistId(id) {
38639
38994
  fs3.writeFileSync(getGistIdPath(), id + "\n", { mode: 384 });
38640
38995
  }
38996
+ /**
38997
+ * Merge the local state-cache into a freshly fetched gist state before it
38998
+ * overwrites the cache (#117). Without this, a prior failed push left its
38999
+ * only copy of the user's changes in state-cache.json, which the next
39000
+ * successful bootstrap silently clobbered. Tombstone-aware mergeStates
39001
+ * keeps both sides' real changes.
39002
+ */
39003
+ mergeCacheInto(fetched) {
39004
+ const cached2 = this.readCache();
39005
+ return cached2 ? mergeStates(cached2, fetched) : fetched;
39006
+ }
38641
39007
  readCache() {
38642
39008
  try {
38643
39009
  const raw = fs3.readFileSync(getCachePath(), "utf-8");
38644
- return ScoutStateSchema.parse(JSON.parse(raw));
39010
+ return parseScoutState(JSON.parse(raw));
38645
39011
  } catch (err) {
38646
39012
  const code = err?.code;
38647
39013
  if (code !== "ENOENT") {
38648
- warn(MODULE13, `Failed to read state cache: ${errorMessage(err)}`);
39014
+ warn(MODULE14, `Failed to read state cache: ${errorMessage(err)}`);
38649
39015
  }
38650
39016
  return null;
38651
39017
  }
@@ -38656,22 +39022,61 @@ var GistStateStore = class {
38656
39022
  mode: 384
38657
39023
  });
38658
39024
  } catch (err) {
38659
- warn(MODULE13, `Failed to write cache: ${errorMessage(err)}`);
39025
+ warn(MODULE14, `Failed to write cache: ${errorMessage(err)}`);
38660
39026
  }
38661
39027
  }
38662
39028
  };
39029
+ var TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
39030
+ function mergeTombstones(local, remote) {
39031
+ const cutoff = Date.now() - TOMBSTONE_TTL_MS;
39032
+ const byUrl = /* @__PURE__ */ new Map();
39033
+ for (const t of [...local, ...remote]) {
39034
+ const existing = byUrl.get(t.url);
39035
+ if (!existing || t.removedAt > existing.removedAt)
39036
+ byUrl.set(t.url, t);
39037
+ }
39038
+ return [...byUrl.values()].filter((t) => {
39039
+ const ts = new Date(t.removedAt).getTime();
39040
+ return !Number.isFinite(ts) || ts >= cutoff;
39041
+ });
39042
+ }
39043
+ function applyTombstones(items, tombstones, urlOf, touchedAtOf) {
39044
+ if (tombstones.length === 0)
39045
+ return items;
39046
+ const byUrl = new Map(tombstones.map((t) => [t.url, t.removedAt]));
39047
+ return items.filter((item) => {
39048
+ const removedAt = byUrl.get(urlOf(item));
39049
+ if (removedAt === void 0)
39050
+ return true;
39051
+ return touchedAtOf(item) > removedAt;
39052
+ });
39053
+ }
38663
39054
  function mergeStates(local, remote) {
39055
+ const tombstones = mergeTombstones(local.tombstones ?? [], remote.tombstones ?? []);
39056
+ const savedResults = applyTombstones(mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []), tombstones, (r) => r.issueUrl, (r) => r.lastSeenAt);
39057
+ const skippedIssues = applyTombstones(mergeSkippedIssues(local.skippedIssues ?? [], remote.skippedIssues ?? []), tombstones, (s) => s.url, (s) => s.skippedAt);
39058
+ const skippedUrls = new Set(skippedIssues.map((s) => s.url));
39059
+ const savedResultsFinal = savedResults.filter((r) => !skippedUrls.has(r.issueUrl));
39060
+ const localPrefsTs = local.preferencesUpdatedAt;
39061
+ const remotePrefsTs = remote.preferencesUpdatedAt;
39062
+ const localPrefsWin = localPrefsTs !== void 0 && (remotePrefsTs === void 0 || localPrefsTs > remotePrefsTs);
39063
+ const preferences = localPrefsWin ? local.preferences : remote.preferences;
39064
+ const preferencesUpdatedAt = pickFresherTimestamp(localPrefsTs, remotePrefsTs);
38664
39065
  return {
39066
+ ...local,
39067
+ ...remote,
38665
39068
  version: 1,
38666
- preferences: remote.preferences,
39069
+ preferences,
39070
+ preferencesUpdatedAt,
39071
+ tombstones,
38667
39072
  repoScores: mergeRepoScores(local.repoScores, remote.repoScores),
38668
39073
  starredRepos: mergeStarredRepos(local, remote),
38669
39074
  starredReposLastFetched: pickFresherTimestamp(local.starredReposLastFetched, remote.starredReposLastFetched),
38670
39075
  mergedPRs: unionByUrl(local.mergedPRs, remote.mergedPRs),
38671
39076
  closedPRs: unionByUrl(local.closedPRs, remote.closedPRs),
38672
39077
  openPRs: unionByUrl(local.openPRs ?? [], remote.openPRs ?? []),
38673
- savedResults: mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []),
38674
- skippedIssues: mergeSkippedIssues(local.skippedIssues ?? [], remote.skippedIssues ?? []),
39078
+ savedResults: savedResultsFinal,
39079
+ skippedIssues,
38675
39080
  lastSearchAt: pickFresherTimestamp(local.lastSearchAt, remote.lastSearchAt),
38676
39081
  lastRunAt: pickFresherTimestamp(local.lastRunAt, remote.lastRunAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
38677
39082
  gistId: remote.gistId ?? local.gistId
@@ -38745,7 +39150,7 @@ function pickFresherTimestamp(a, b) {
38745
39150
  // ../core/dist/core/local-state.js
38746
39151
  var fs4 = __toESM(require("fs"), 1);
38747
39152
  var path4 = __toESM(require("path"), 1);
38748
- var MODULE14 = "local-state";
39153
+ var MODULE15 = "local-state";
38749
39154
  function getStatePath() {
38750
39155
  return path4.join(getDataDir(), "state.json");
38751
39156
  }
@@ -38753,23 +39158,40 @@ function loadLocalState() {
38753
39158
  const statePath = getStatePath();
38754
39159
  try {
38755
39160
  const raw = fs4.readFileSync(statePath, "utf-8");
38756
- return ScoutStateSchema.parse(JSON.parse(raw));
39161
+ return parseScoutState(JSON.parse(raw));
38757
39162
  } catch (err) {
38758
39163
  const code = err?.code;
38759
39164
  if (code === "ENOENT") {
38760
39165
  return ScoutStateSchema.parse({ version: 1 });
38761
39166
  }
38762
- warn(MODULE14, `Failed to load state from ${statePath}: ${errorMessage(err)}. Using defaults.`);
39167
+ warn(MODULE15, `Failed to load state from ${statePath}: ${errorMessage(err)}. Using defaults.`);
38763
39168
  try {
38764
39169
  const backupPath = `${statePath}.corrupt.${Date.now()}`;
38765
39170
  fs4.copyFileSync(statePath, backupPath);
38766
- warn(MODULE14, `Corrupt state backed up to ${backupPath}`);
39171
+ warn(MODULE15, `Corrupt state backed up to ${backupPath}`);
38767
39172
  } catch (backupErr) {
38768
- warn(MODULE14, `Failed to back up corrupt state: ${errorMessage(backupErr)}`);
39173
+ warn(MODULE15, `Failed to back up corrupt state: ${errorMessage(backupErr)}`);
38769
39174
  }
38770
39175
  return ScoutStateSchema.parse({ version: 1 });
38771
39176
  }
38772
39177
  }
39178
+ var tmpCounter = 0;
39179
+ function saveLocalState(state) {
39180
+ const statePath = getStatePath();
39181
+ const tmpPath = `${statePath}.tmp.${process.pid}.${tmpCounter++}`;
39182
+ const data = JSON.stringify(state, null, 2) + "\n";
39183
+ fs4.writeFileSync(tmpPath, data, { mode: 384 });
39184
+ try {
39185
+ fs4.renameSync(tmpPath, statePath);
39186
+ } catch (err) {
39187
+ try {
39188
+ fs4.unlinkSync(tmpPath);
39189
+ } catch {
39190
+ }
39191
+ throw err;
39192
+ }
39193
+ debug(MODULE15, "State saved");
39194
+ }
38773
39195
 
38774
39196
  // ../core/dist/scout.js
38775
39197
  function offlineModeMessage(reason) {
@@ -38824,8 +39246,12 @@ function toGistOctokit(octokit) {
38824
39246
  };
38825
39247
  }
38826
39248
  async function createScout(config2) {
39249
+ if (config2.logLevel !== void 0) {
39250
+ setLogLevel(config2.logLevel);
39251
+ }
38827
39252
  let state;
38828
39253
  let gistStore = null;
39254
+ let persistLocal = false;
38829
39255
  if (config2.persistence === "provided") {
38830
39256
  state = config2.initialState;
38831
39257
  } else if (config2.persistence === "gist") {
@@ -38842,36 +39268,61 @@ async function createScout(config2) {
38842
39268
  state.gistId = result.gistId;
38843
39269
  }
38844
39270
  } else {
38845
- state = ScoutStateSchema.parse({ version: 1 });
39271
+ state = loadLocalState();
39272
+ persistLocal = true;
38846
39273
  }
38847
- return new OssScout(config2.githubToken, state, gistStore);
39274
+ return new OssScout(config2.githubToken, state, gistStore, { persistLocal });
38848
39275
  }
38849
39276
  var OssScout = class {
38850
39277
  githubToken;
38851
39278
  gistStore;
38852
39279
  state;
38853
39280
  dirty = false;
38854
- constructor(githubToken, initialState, gistStore = null) {
39281
+ /** When true, checkpoint() also writes ~/.oss-scout/state.json. */
39282
+ persistLocal;
39283
+ constructor(githubToken, initialState, gistStore = null, opts = {}) {
38855
39284
  this.githubToken = githubToken;
38856
39285
  this.gistStore = gistStore;
38857
39286
  this.state = initialState;
39287
+ this.persistLocal = opts.persistLocal ?? false;
38858
39288
  }
38859
39289
  // ── Search ──────────────────────────────────────────────────────────
39290
+ /**
39291
+ * Drop stale disk-cache entries. Called at the top of every cache-burning
39292
+ * entry point (search, features, vetList); without it ~/.oss-scout/cache
39293
+ * grows without bound. evictStale never throws (fs errors degrade to warn).
39294
+ */
39295
+ evictStaleCacheEntries() {
39296
+ getHttpCache().evictStale();
39297
+ }
38860
39298
  /**
38861
39299
  * Multi-strategy issue search. Returns scored, sorted candidates.
38862
39300
  * Automatically culls expired skip entries and filters skipped issues.
38863
39301
  */
38864
39302
  async search(options) {
39303
+ this.evictStaleCacheEntries();
38865
39304
  this.cullExpiredSkips();
38866
39305
  const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
38867
39306
  const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
39307
+ const prefs = this.state.preferences;
39308
+ const prefLangs = prefs.preferLanguages ?? [];
39309
+ const prefRepos = prefs.preferRepos ?? [];
39310
+ const preferLanguages = options?.preferLanguages ?? (prefLangs.length > 0 ? prefLangs : void 0);
39311
+ const preferRepos = options?.preferRepos ?? (prefRepos.length > 0 ? prefRepos : void 0);
39312
+ const diversityRatio = options?.diversityRatio ?? prefs.diversityRatio ?? 0;
38868
39313
  const { candidates, strategiesUsed } = await discovery.searchIssues({
38869
39314
  maxResults: options?.maxResults,
38870
39315
  strategies: options?.strategies,
38871
39316
  skippedUrls,
38872
- preferLanguages: options?.preferLanguages,
38873
- preferRepos: options?.preferRepos
39317
+ preferLanguages,
39318
+ preferRepos,
39319
+ diversityRatio,
39320
+ interPhaseDelayMs: options?.interPhaseDelayMs,
39321
+ broadPhaseDelayMs: options?.broadPhaseDelayMs
38874
39322
  });
39323
+ for (const c of candidates) {
39324
+ this.updateRepoSignalsFromHealth(c.projectHealth);
39325
+ }
38875
39326
  this.state.lastSearchAt = (/* @__PURE__ */ new Date()).toISOString();
38876
39327
  this.dirty = true;
38877
39328
  return {
@@ -38882,6 +39333,28 @@ var OssScout = class {
38882
39333
  strategiesUsed
38883
39334
  };
38884
39335
  }
39336
+ /**
39337
+ * Populate the `hasActiveMaintainers` repo-score signal from a freshly
39338
+ * computed projectHealth (#167). It was initialized false and never set, so
39339
+ * calculateScore's +1 active-maintainers weight was inert; `isActive`
39340
+ * (recent commit activity) is a real, already-computed proxy.
39341
+ *
39342
+ * `isResponsive` and `avgResponseDays` are deliberately NOT set here:
39343
+ * `projectHealth.avgIssueResponseDays` is a hardcoded `0` placeholder
39344
+ * (repo-health.ts), so deriving responsiveness from it would award +1 to
39345
+ * every repo — a fake signal worse than the inert one. Real responsiveness
39346
+ * needs an actual response-time measurement (extra API calls), deferred.
39347
+ * `hasHostileComments` likewise stays a host-settable capability (it needs
39348
+ * comment sentiment, out of scope). A failed health check is skipped so its
39349
+ * neutral-default fields don't pollute the score.
39350
+ */
39351
+ updateRepoSignalsFromHealth(health) {
39352
+ if (health.checkFailed)
39353
+ return;
39354
+ this.updateRepoScore(health.repo, {
39355
+ signals: { hasActiveMaintainers: health.isActive }
39356
+ });
39357
+ }
38885
39358
  /**
38886
39359
  * Vet a single issue URL for claimability.
38887
39360
  */
@@ -38904,6 +39377,7 @@ var OssScout = class {
38904
39377
  * preferences and excluded repos/orgs.
38905
39378
  */
38906
39379
  async features(options) {
39380
+ this.evictStaleCacheEntries();
38907
39381
  const count = options?.count ?? 10;
38908
39382
  const octokit = getOctokit(this.githubToken);
38909
39383
  const vetter = new IssueVetter(octokit, this);
@@ -38935,6 +39409,7 @@ var OssScout = class {
38935
39409
  * Optionally prunes unavailable issues from saved results.
38936
39410
  */
38937
39411
  async vetList(options) {
39412
+ this.evictStaleCacheEntries();
38938
39413
  const saved = this.getSavedResults();
38939
39414
  const concurrency = options?.concurrency ?? 5;
38940
39415
  const results = [];
@@ -38950,6 +39425,7 @@ var OssScout = class {
38950
39425
  number: item.number,
38951
39426
  title: item.title,
38952
39427
  status: this.classifyVetResult(candidate),
39428
+ ok: true,
38953
39429
  recommendation: candidate.recommendation,
38954
39430
  viabilityScore: candidate.viabilityScore
38955
39431
  });
@@ -38966,6 +39442,7 @@ var OssScout = class {
38966
39442
  number: item.number,
38967
39443
  title: item.title,
38968
39444
  status: isGone ? "closed" : "error",
39445
+ ok: false,
38969
39446
  errorMessage: errorMessage(error48)
38970
39447
  });
38971
39448
  }).finally(() => {
@@ -38991,17 +39468,41 @@ var OssScout = class {
38991
39468
  hasPR: results.filter((r) => r.status === "has_pr").length,
38992
39469
  errors: results.filter((r) => r.status === "error").length
38993
39470
  };
39471
+ const prevStatus = new Map((this.state.savedResults ?? []).map((r) => [r.issueUrl, r.lastStatus]));
39472
+ const transitions = results.filter((r) => r.status !== "error").filter((r) => {
39473
+ const prev = prevStatus.get(r.issueUrl);
39474
+ return prev !== void 0 && prev !== r.status;
39475
+ }).map((r) => ({
39476
+ issueUrl: r.issueUrl,
39477
+ repo: r.repo,
39478
+ number: r.number,
39479
+ from: prevStatus.get(r.issueUrl),
39480
+ to: r.status
39481
+ }));
39482
+ const currentStatus = new Map(results.map((r) => [r.issueUrl, r.status]));
39483
+ for (const saved2 of this.state.savedResults ?? []) {
39484
+ const status = currentStatus.get(saved2.issueUrl);
39485
+ if (status !== void 0 && status !== "error") {
39486
+ saved2.lastStatus = status;
39487
+ }
39488
+ }
39489
+ if (results.length > 0)
39490
+ this.dirty = true;
38994
39491
  let prunedCount;
38995
39492
  if (options?.prune) {
38996
39493
  const unavailableUrls = new Set(results.filter((r) => r.status !== "still_available").map((r) => r.issueUrl));
38997
39494
  const before = (this.state.savedResults ?? []).length;
38998
39495
  this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
38999
39496
  prunedCount = before - (this.state.savedResults?.length ?? 0);
39497
+ if (prunedCount > 0)
39498
+ this.addTombstones([...unavailableUrls]);
39000
39499
  this.dirty = true;
39001
39500
  }
39002
- return { results, summary, prunedCount };
39501
+ return { results, summary, prunedCount, transitions };
39003
39502
  }
39004
39503
  classifyVetResult(candidate) {
39504
+ if (candidate.issueState === "closed")
39505
+ return "closed";
39005
39506
  const checks = candidate.vettingResult.checks;
39006
39507
  if (!checks.noExistingPR)
39007
39508
  return "has_pr";
@@ -39036,19 +39537,35 @@ var OssScout = class {
39036
39537
  getProjectCategories() {
39037
39538
  return this.state.preferences.projectCategories;
39038
39539
  }
39540
+ /** Configured GitHub username (used to classify own vs competing PRs, #166). */
39541
+ getGitHubUsername() {
39542
+ return this.state.preferences.githubUsername;
39543
+ }
39039
39544
  getRepoScore(repo) {
39040
39545
  const score = this.state.repoScores[repo];
39041
39546
  return score ? score.score : null;
39042
39547
  }
39043
39548
  /**
39044
- * Optional SLM pre-triage config read from preferences (oss-autopilot#1122).
39045
- * Empty `model` disables the call; the vetter treats it as a no-op.
39549
+ * Number of the user's PRs closed without merge in this repo (#125).
39550
+ * Prefers the tracked repo score; falls back to counting closedPRs so the
39551
+ * scoring penalty works even before a score record exists.
39552
+ */
39553
+ getClosedWithoutMergeCount(repo) {
39554
+ const score = this.state.repoScores[repo];
39555
+ if (score)
39556
+ return score.closedWithoutMergeCount;
39557
+ return (this.state.closedPRs ?? []).filter((p) => extractRepoFromUrl(p.url) === repo).length;
39558
+ }
39559
+ /**
39560
+ * SLM pre-triage config read from preferences (oss-autopilot#1122). Returns
39561
+ * `null` when no `slmTriageModel` is configured — the vetter skips the SLM
39562
+ * call entirely (#158).
39046
39563
  */
39047
39564
  getSLMTriageConfig() {
39048
- return {
39049
- model: this.state.preferences.slmTriageModel ?? "",
39050
- host: this.state.preferences.slmTriageHost ?? ""
39051
- };
39565
+ const model = this.state.preferences.slmTriageModel ?? "";
39566
+ if (!model)
39567
+ return null;
39568
+ return { model, host: this.state.preferences.slmTriageHost ?? "" };
39052
39569
  }
39053
39570
  /** Get current preferences (read-only). */
39054
39571
  getPreferences() {
@@ -39102,6 +39619,73 @@ var OssScout = class {
39102
39619
  ];
39103
39620
  this.dirty = true;
39104
39621
  }
39622
+ /**
39623
+ * Reconcile tracked open PRs against their current GitHub state (#164).
39624
+ *
39625
+ * `state.openPRs` was append-only — nothing transitioned an open PR to
39626
+ * merged/closed, so getReposWithOpenPRs() over-reported forever once a PR
39627
+ * merged. This checks each open PR, records merges/closures (which updates
39628
+ * the repo score), prunes resolved entries, and checkpoints. Cheaper than a
39629
+ * full bootstrap, so a host can call it on daily startup. Transient errors
39630
+ * leave the entry in place; auth/rate-limit failures propagate.
39631
+ */
39632
+ async syncOpenPRs() {
39633
+ const octokit = getOctokit(this.githubToken);
39634
+ const open = this.state.openPRs ?? [];
39635
+ const result = {
39636
+ checked: open.length,
39637
+ merged: 0,
39638
+ closed: 0,
39639
+ stillOpen: 0,
39640
+ errors: 0
39641
+ };
39642
+ const remaining = [];
39643
+ for (const pr of open) {
39644
+ const parsed = parseGitHubUrl(pr.url);
39645
+ if (!parsed || parsed.type !== "pull") {
39646
+ remaining.push(pr);
39647
+ result.errors++;
39648
+ continue;
39649
+ }
39650
+ const repoFullName = `${parsed.owner}/${parsed.repo}`;
39651
+ try {
39652
+ const { data } = await octokit.pulls.get({
39653
+ owner: parsed.owner,
39654
+ repo: parsed.repo,
39655
+ pull_number: parsed.number
39656
+ });
39657
+ if (data.merged) {
39658
+ this.recordMergedPR({
39659
+ url: pr.url,
39660
+ title: pr.title,
39661
+ mergedAt: data.merged_at ?? (/* @__PURE__ */ new Date()).toISOString(),
39662
+ repo: repoFullName
39663
+ });
39664
+ result.merged++;
39665
+ } else if (data.state === "closed") {
39666
+ this.recordClosedPR({
39667
+ url: pr.url,
39668
+ title: pr.title,
39669
+ closedAt: data.closed_at ?? (/* @__PURE__ */ new Date()).toISOString(),
39670
+ repo: repoFullName
39671
+ });
39672
+ result.closed++;
39673
+ } else {
39674
+ remaining.push(pr);
39675
+ result.stillOpen++;
39676
+ }
39677
+ } catch (err) {
39678
+ rethrowIfFatal(err);
39679
+ warn("scout", `sync: could not check ${pr.url}: ${errorMessage(err)}`);
39680
+ remaining.push(pr);
39681
+ result.errors++;
39682
+ }
39683
+ }
39684
+ this.state.openPRs = remaining;
39685
+ this.dirty = true;
39686
+ await this.checkpoint();
39687
+ return result;
39688
+ }
39105
39689
  /**
39106
39690
  * Update repo score with observed signals.
39107
39691
  */
@@ -39136,6 +39720,7 @@ var OssScout = class {
39136
39720
  */
39137
39721
  updatePreferences(updates) {
39138
39722
  this.state.preferences = { ...this.state.preferences, ...updates };
39723
+ this.state.preferencesUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
39139
39724
  this.dirty = true;
39140
39725
  }
39141
39726
  /**
@@ -39185,9 +39770,25 @@ var OssScout = class {
39185
39770
  * Clear all saved results.
39186
39771
  */
39187
39772
  clearResults() {
39773
+ this.addTombstones((this.state.savedResults ?? []).map((r) => r.issueUrl));
39188
39774
  this.state.savedResults = [];
39189
39775
  this.dirty = true;
39190
39776
  }
39777
+ /**
39778
+ * Record deletion tombstones (#117) so a later gist merge does not
39779
+ * resurrect these URLs from another machine's copy. A re-add with a newer
39780
+ * timestamp overrides the tombstone in mergeStates.
39781
+ */
39782
+ addTombstones(urls) {
39783
+ if (urls.length === 0)
39784
+ return;
39785
+ const removedAt = (/* @__PURE__ */ new Date()).toISOString();
39786
+ const existing = this.state.tombstones ?? [];
39787
+ const byUrl = new Map(existing.map((t) => [t.url, t]));
39788
+ for (const url2 of urls)
39789
+ byUrl.set(url2, { url: url2, removedAt });
39790
+ this.state.tombstones = [...byUrl.values()];
39791
+ }
39191
39792
  // ── Skip List ───────────────────────────────────────────────────────
39192
39793
  /**
39193
39794
  * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
@@ -39221,13 +39822,17 @@ var OssScout = class {
39221
39822
  * Remove a specific issue from the skip list.
39222
39823
  */
39223
39824
  unskipIssue(url2) {
39825
+ const before = (this.state.skippedIssues ?? []).length;
39224
39826
  this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url2);
39827
+ if (this.state.skippedIssues.length < before)
39828
+ this.addTombstones([url2]);
39225
39829
  this.dirty = true;
39226
39830
  }
39227
39831
  /**
39228
39832
  * Clear all skipped issues.
39229
39833
  */
39230
39834
  clearSkippedIssues() {
39835
+ this.addTombstones((this.state.skippedIssues ?? []).map((s) => s.url));
39231
39836
  this.state.skippedIssues = [];
39232
39837
  this.dirty = true;
39233
39838
  }
@@ -39271,6 +39876,13 @@ var OssScout = class {
39271
39876
  if (!ok)
39272
39877
  return false;
39273
39878
  }
39879
+ if (this.persistLocal) {
39880
+ try {
39881
+ saveLocalState(this.state);
39882
+ } catch {
39883
+ return false;
39884
+ }
39885
+ }
39274
39886
  this.dirty = false;
39275
39887
  return true;
39276
39888
  }
@@ -39309,8 +39921,152 @@ var OssScout = class {
39309
39921
  }
39310
39922
  };
39311
39923
 
39924
+ // ../core/dist/core/preference-fields.js
39925
+ var FIELD_CONFIGS = {
39926
+ githubUsername: { type: "string" },
39927
+ languages: { type: "array" },
39928
+ labels: { type: "array" },
39929
+ scope: { type: "enum-array", validValues: IssueScopeSchema.options },
39930
+ excludeRepos: { type: "array" },
39931
+ excludeOrgs: { type: "array" },
39932
+ aiPolicyBlocklist: { type: "array" },
39933
+ projectCategories: {
39934
+ type: "enum-array",
39935
+ validValues: ProjectCategorySchema.options
39936
+ },
39937
+ minStars: { type: "number" },
39938
+ maxIssueAgeDays: { type: "number" },
39939
+ includeDocIssues: { type: "boolean" },
39940
+ minRepoScoreThreshold: { type: "number" },
39941
+ interPhaseDelayMs: { type: "number" },
39942
+ persistence: { type: "enum", validValues: PersistenceModeSchema.options },
39943
+ defaultStrategy: {
39944
+ type: "enum-array",
39945
+ validValues: SearchStrategySchema.options
39946
+ },
39947
+ broadPhaseDelayMs: { type: "number" },
39948
+ skipBroadWhenSufficientResults: { type: "number" },
39949
+ preferLanguages: { type: "array" },
39950
+ preferRepos: { type: "array" },
39951
+ diversityRatio: { type: "float" },
39952
+ slmTriageModel: { type: "string" },
39953
+ slmTriageHost: { type: "string" },
39954
+ featuresAnchorThreshold: { type: "number" },
39955
+ featuresSplitRatio: { type: "float" }
39956
+ };
39957
+ var PREFERENCE_KEYS = Object.keys(ScoutPreferencesSchema.shape);
39958
+ var SORTED_PREFERENCE_KEYS = [
39959
+ ...PREFERENCE_KEYS
39960
+ ].sort();
39961
+ function parseBoolean(value) {
39962
+ const lower = value.toLowerCase();
39963
+ if (lower === "true" || lower === "yes")
39964
+ return true;
39965
+ if (lower === "false" || lower === "no")
39966
+ return false;
39967
+ throw new ValidationError(`Invalid boolean value: "${value}". Use true/false or yes/no.`);
39968
+ }
39969
+ function parseIntValue(value, key) {
39970
+ const num = parseInt(value, 10);
39971
+ if (isNaN(num)) {
39972
+ throw new ValidationError(`Invalid number for "${key}": "${value}"`);
39973
+ }
39974
+ return num;
39975
+ }
39976
+ function parseFloatValue(value, key) {
39977
+ const num = Number.parseFloat(value);
39978
+ if (isNaN(num)) {
39979
+ throw new ValidationError(`Invalid number for "${key}": "${value}"`);
39980
+ }
39981
+ return num;
39982
+ }
39983
+ function parseArrayValue(value) {
39984
+ return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
39985
+ }
39986
+ function updateArray(current, value) {
39987
+ if (value.startsWith("+")) {
39988
+ const toAdd = parseArrayValue(value.slice(1));
39989
+ const merged = [...current];
39990
+ for (const item of toAdd) {
39991
+ if (!merged.includes(item))
39992
+ merged.push(item);
39993
+ }
39994
+ return merged;
39995
+ }
39996
+ if (value.startsWith("-")) {
39997
+ const toRemove = new Set(parseArrayValue(value.slice(1)));
39998
+ return current.filter((item) => !toRemove.has(item));
39999
+ }
40000
+ return parseArrayValue(value);
40001
+ }
40002
+ function applyPreferenceField(preferences, key, value) {
40003
+ const field = FIELD_CONFIGS[key];
40004
+ if (!field) {
40005
+ throw new ValidationError(`Unknown config key: "${key}". Valid keys: ${SORTED_PREFERENCE_KEYS.join(", ")}`);
40006
+ }
40007
+ const prefs = { ...preferences };
40008
+ switch (field.type) {
40009
+ case "string":
40010
+ prefs[key] = value;
40011
+ break;
40012
+ case "boolean":
40013
+ prefs[key] = parseBoolean(value);
40014
+ break;
40015
+ case "number":
40016
+ prefs[key] = parseIntValue(value, key);
40017
+ break;
40018
+ case "float":
40019
+ prefs[key] = parseFloatValue(value, key);
40020
+ break;
40021
+ case "array": {
40022
+ const current = prefs[key] ?? [];
40023
+ prefs[key] = updateArray(current, value);
40024
+ break;
40025
+ }
40026
+ case "enum": {
40027
+ const validValues = field.validValues;
40028
+ if (!validValues.includes(value)) {
40029
+ throw new ValidationError(`Invalid value for "${key}": "${value}". Valid: ${validValues.join(", ")}`);
40030
+ }
40031
+ prefs[key] = value;
40032
+ break;
40033
+ }
40034
+ case "enum-array": {
40035
+ const current = prefs[key] ?? [];
40036
+ const updated = updateArray(current, value);
40037
+ const validValues = field.validValues;
40038
+ const invalid = updated.filter((s) => !validValues.includes(s));
40039
+ if (invalid.length > 0) {
40040
+ throw new ValidationError(`Invalid value(s) for "${key}": ${invalid.join(", ")}. Valid: ${validValues.join(", ")}`);
40041
+ }
40042
+ if (key === "scope") {
40043
+ prefs[key] = updated.length > 0 ? updated : void 0;
40044
+ } else {
40045
+ prefs[key] = updated;
40046
+ }
40047
+ break;
40048
+ }
40049
+ }
40050
+ return ScoutPreferencesSchema.parse(prefs);
40051
+ }
40052
+
40053
+ // ../core/dist/commands/validation.js
40054
+ var ISSUE_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/;
40055
+ var MAX_URL_LENGTH = 2048;
40056
+ function validateGitHubUrl(url2, pattern, entityType) {
40057
+ if (pattern.test(url2))
40058
+ return;
40059
+ throw new ValidationError(`Invalid ${entityType} URL: ${url2}. Expected format: https://github.com/owner/repo/issues/123`);
40060
+ }
40061
+ function validateUrl(url2) {
40062
+ if (url2.length > MAX_URL_LENGTH) {
40063
+ throw new ValidationError(`URL exceeds maximum length of ${MAX_URL_LENGTH} characters`);
40064
+ }
40065
+ return url2;
40066
+ }
40067
+
39312
40068
  // src/tools.ts
39313
- var TOOL_TIMEOUT_MS = 6e4;
40069
+ var TOOL_TIMEOUT_MS = 3e5;
39314
40070
  function withTimeout(promise2, ms = TOOL_TIMEOUT_MS) {
39315
40071
  let timeoutId;
39316
40072
  const timer = new Promise((_, reject) => {
@@ -39335,9 +40091,18 @@ function registerTools(server, scout) {
39335
40091
  ),
39336
40092
  preferRepos: external_exports3.array(external_exports3.string()).optional().describe(
39337
40093
  "Soft-boost ranking for candidates in one of these `owner/repo` slugs. Stronger weight than language match (#1244). Not a filter; only reorders."
40094
+ ),
40095
+ diversityRatio: external_exports3.number().min(0).max(1).optional().describe(
40096
+ "Diversity counterweight (#1244): fraction of result slots reserved for candidates that matched NEITHER preference list. Counters echo-chamber bias when boosts accumulate over time. 0 disables, 1 reserves every slot for diversity. Default 0."
39338
40097
  )
39339
40098
  },
39340
- async ({ maxResults, strategies, preferLanguages, preferRepos }) => {
40099
+ async ({
40100
+ maxResults,
40101
+ strategies,
40102
+ preferLanguages,
40103
+ preferRepos,
40104
+ diversityRatio
40105
+ }) => {
39341
40106
  try {
39342
40107
  const parsedStrategies = strategies ? strategies.split(",").map((s) => SearchStrategySchema.parse(s.trim())) : void 0;
39343
40108
  const result = await withTimeout(
@@ -39345,7 +40110,12 @@ function registerTools(server, scout) {
39345
40110
  maxResults: maxResults ?? 10,
39346
40111
  strategies: parsedStrategies,
39347
40112
  preferLanguages,
39348
- preferRepos
40113
+ preferRepos,
40114
+ diversityRatio,
40115
+ // No fixed inter-phase sleeps in the request/response MCP context;
40116
+ // the budget tracker still paces the GitHub calls (#143)
40117
+ interPhaseDelayMs: 0,
40118
+ broadPhaseDelayMs: 0
39349
40119
  })
39350
40120
  );
39351
40121
  scout.saveResults(result.candidates);
@@ -39426,6 +40196,24 @@ function registerTools(server, scout) {
39426
40196
  };
39427
40197
  }
39428
40198
  );
40199
+ server.tool(
40200
+ "sync",
40201
+ "Reconcile tracked open PRs against GitHub \u2014 mark merged/closed and recompute repo scores. Cheap; good for a daily startup call.",
40202
+ async () => {
40203
+ try {
40204
+ const result = await withTimeout(scout.syncOpenPRs());
40205
+ return {
40206
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
40207
+ };
40208
+ } catch (err) {
40209
+ const msg = err instanceof Error ? err.message : String(err);
40210
+ return {
40211
+ content: [{ type: "text", text: `Error: ${msg}` }],
40212
+ isError: true
40213
+ };
40214
+ }
40215
+ }
40216
+ );
39429
40217
  server.tool(
39430
40218
  "skip",
39431
40219
  "Skip, unskip, list, or clear skipped issues. Skipped issues are excluded from future searches and auto-expire after 90 days.",
@@ -39456,7 +40244,7 @@ function registerTools(server, scout) {
39456
40244
  const count = scout.getSkippedIssues().length;
39457
40245
  scout.clearSkippedIssues();
39458
40246
  const synced2 = await scout.checkpoint();
39459
- const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
40247
+ const syncNote2 = synced2 ? "" : " (warning: failed to persist to disk)";
39460
40248
  return {
39461
40249
  content: [
39462
40250
  {
@@ -39470,7 +40258,7 @@ function registerTools(server, scout) {
39470
40258
  const wasPresent = scout.getSkippedIssues().some((s) => s.url === issueUrl);
39471
40259
  scout.unskipIssue(issueUrl);
39472
40260
  const synced2 = await scout.checkpoint();
39473
- const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
40261
+ const syncNote2 = synced2 ? "" : " (warning: failed to persist to disk)";
39474
40262
  return {
39475
40263
  content: [
39476
40264
  {
@@ -39480,6 +40268,8 @@ function registerTools(server, scout) {
39480
40268
  ]
39481
40269
  };
39482
40270
  }
40271
+ validateUrl(issueUrl);
40272
+ validateGitHubUrl(issueUrl, ISSUE_URL_PATTERN, "issue");
39483
40273
  const alreadySkipped = scout.getSkippedIssues().some((s) => s.url === issueUrl);
39484
40274
  if (alreadySkipped) {
39485
40275
  return {
@@ -39496,7 +40286,7 @@ function registerTools(server, scout) {
39496
40286
  } : void 0;
39497
40287
  scout.skipIssue(issueUrl, metadata);
39498
40288
  const synced = await scout.checkpoint();
39499
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
40289
+ const syncNote = synced ? "" : " (warning: failed to persist to disk)";
39500
40290
  return {
39501
40291
  content: [{ type: "text", text: `Skipped: ${issueUrl}${syncNote}` }]
39502
40292
  };
@@ -39509,101 +40299,31 @@ function registerTools(server, scout) {
39509
40299
  }
39510
40300
  }
39511
40301
  );
39512
- const VALID_KEYS = Object.keys(ScoutPreferencesSchema.shape);
39513
- const ARRAY_KEYS = /* @__PURE__ */ new Set([
39514
- "languages",
39515
- "labels",
39516
- "projectCategories",
39517
- "excludeRepos",
39518
- "excludeOrgs",
39519
- "aiPolicyBlocklist",
39520
- "scope",
39521
- "defaultStrategy"
39522
- ]);
39523
- const NUMBER_KEYS = /* @__PURE__ */ new Set([
39524
- "minStars",
39525
- "maxIssueAgeDays",
39526
- "minRepoScoreThreshold",
39527
- "interPhaseDelayMs",
39528
- "broadPhaseDelayMs",
39529
- "skipBroadWhenSufficientResults",
39530
- "featuresAnchorThreshold",
39531
- "featuresSplitRatio"
39532
- ]);
39533
- const BOOLEAN_KEYS = /* @__PURE__ */ new Set(["includeDocIssues"]);
39534
40302
  server.tool(
39535
40303
  "config-set",
39536
40304
  "Update an oss-scout configuration preference",
39537
40305
  {
39538
40306
  key: external_exports3.string().describe(
39539
- "Preference key to update (e.g. languages, minStars, excludeRepos)"
40307
+ `Preference key to update. One of: ${SORTED_PREFERENCE_KEYS.join(", ")}`
39540
40308
  ),
39541
40309
  value: external_exports3.string().describe(
39542
- "New value \u2014 comma-separated for arrays, plain string for scalars"
40310
+ 'New value. Comma-separated for arrays; prefix with + to append or - to remove items (e.g. "+spam/repo"). Plain string/number/boolean for scalars.'
39543
40311
  )
39544
40312
  },
39545
40313
  async ({ key, value }) => {
39546
- if (!VALID_KEYS.includes(key)) {
39547
- return {
39548
- content: [
39549
- {
39550
- type: "text",
39551
- text: `Unknown config key: "${key}". Valid keys: ${VALID_KEYS.join(", ")}`
39552
- }
39553
- ],
39554
- isError: true
39555
- };
39556
- }
39557
- let parsed;
39558
- if (ARRAY_KEYS.has(key)) {
39559
- try {
39560
- parsed = JSON.parse(value);
39561
- if (!Array.isArray(parsed)) {
39562
- parsed = value.split(",").map((s) => s.trim()).filter(Boolean);
39563
- }
39564
- } catch {
39565
- parsed = value.split(",").map((s) => s.trim()).filter(Boolean);
39566
- }
39567
- } else if (NUMBER_KEYS.has(key)) {
39568
- const num = Number(value);
39569
- if (isNaN(num)) {
39570
- return {
39571
- content: [
39572
- { type: "text", text: `Invalid number for "${key}": "${value}"` }
39573
- ],
39574
- isError: true
39575
- };
39576
- }
39577
- parsed = num;
39578
- } else if (BOOLEAN_KEYS.has(key)) {
39579
- const lower = value.toLowerCase();
39580
- if (lower === "true" || lower === "yes") parsed = true;
39581
- else if (lower === "false" || lower === "no") parsed = false;
39582
- else {
39583
- return {
39584
- content: [
39585
- {
39586
- type: "text",
39587
- text: `Invalid boolean for "${key}": "${value}". Use true/false or yes/no.`
39588
- }
39589
- ],
39590
- isError: true
39591
- };
39592
- }
39593
- } else {
39594
- parsed = value;
39595
- }
39596
- const currentPrefs = scout.getPreferences();
39597
- const candidate = { ...currentPrefs, [key]: parsed };
39598
- const result = ScoutPreferencesSchema.safeParse(candidate);
39599
- if (!result.success) {
39600
- const issues = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
40314
+ let validated;
40315
+ try {
40316
+ validated = applyPreferenceField(scout.getPreferences(), key, value);
40317
+ } catch (err) {
40318
+ const msg = err instanceof Error ? err.message : String(err);
39601
40319
  return {
39602
- content: [{ type: "text", text: `Validation error: ${issues}` }],
40320
+ content: [{ type: "text", text: msg }],
39603
40321
  isError: true
39604
40322
  };
39605
40323
  }
39606
- scout.updatePreferences({ [key]: parsed });
40324
+ scout.updatePreferences({
40325
+ [key]: validated[key]
40326
+ });
39607
40327
  await scout.checkpoint();
39608
40328
  const updated = scout.getPreferences();
39609
40329
  return {
@@ -39663,25 +40383,34 @@ function registerResources(server, scout) {
39663
40383
  }
39664
40384
 
39665
40385
  // src/index.ts
39666
- async function main() {
39667
- const token = getGitHubToken();
39668
- if (!token) {
39669
- process.stderr.write(
39670
- "oss-scout-mcp: GitHub token required.\nSet GITHUB_TOKEN or run `gh auth login`.\n"
39671
- );
39672
- process.exit(1);
39673
- }
39674
- const scout = await createScout({ githubToken: token });
40386
+ var SERVER_INFO = {
40387
+ name: "oss-scout-mcp",
40388
+ version: "0.9.0"
40389
+ // x-release-please-version
40390
+ };
40391
+ function createServer(scout) {
39675
40392
  const server = new McpServer(
39676
- { name: "oss-scout-mcp", version: "0.2.1" },
40393
+ { name: SERVER_INFO.name, version: SERVER_INFO.version },
39677
40394
  { capabilities: { resources: {}, tools: {} } }
39678
40395
  );
39679
40396
  registerTools(server, scout);
39680
40397
  registerResources(server, scout);
39681
- const transport = new StdioServerTransport();
39682
- await server.connect(transport);
40398
+ return server;
39683
40399
  }
39684
- main().catch((err) => {
40400
+ async function runServer() {
40401
+ const token = getGitHubToken();
40402
+ if (!token) {
40403
+ throw new Error(
40404
+ "GitHub token required. Set GITHUB_TOKEN or run `gh auth login`."
40405
+ );
40406
+ }
40407
+ const scout = await createScout({ githubToken: token, persistence: "local" });
40408
+ const server = createServer(scout);
40409
+ await server.connect(new StdioServerTransport());
40410
+ }
40411
+
40412
+ // src/bin.ts
40413
+ runServer().catch((err) => {
39685
40414
  process.stderr.write(
39686
40415
  `oss-scout-mcp fatal: ${err instanceof Error ? err.message : String(err)}
39687
40416
  `