@oss-scout/mcp 0.8.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,28 +37903,30 @@ 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
- }
37627
- }
37926
+ if (score === 0)
37927
+ return c;
37928
+ return { ...c, personalization: { kind: "boosted", score, reasons } };
37929
+ });
37628
37930
  }
37629
37931
  function applyDiversityRatio(candidates, maxResults, diversityRatio) {
37630
37932
  if (maxResults <= 0)
@@ -37649,10 +37951,9 @@ function applyDiversityRatio(candidates, maxResults, diversityRatio) {
37649
37951
  break;
37650
37952
  if (seen.has(c.issue.url))
37651
37953
  continue;
37652
- if (c.boostScore && c.boostScore > 0)
37954
+ if (boostScoreOf(c) > 0)
37653
37955
  continue;
37654
- c.diversitySlot = true;
37655
- picks.push(c);
37956
+ picks.push({ ...c, personalization: { kind: "diversity" } });
37656
37957
  seen.add(c.issue.url);
37657
37958
  }
37658
37959
  for (const c of candidates) {
@@ -37667,7 +37968,7 @@ function applyDiversityRatio(candidates, maxResults, diversityRatio) {
37667
37968
  }
37668
37969
 
37669
37970
  // ../core/dist/core/issue-discovery.js
37670
- var MODULE10 = "issue-discovery";
37971
+ var MODULE11 = "issue-discovery";
37671
37972
  var LOW_BUDGET_THRESHOLD = 20;
37672
37973
  var CRITICAL_BUDGET_THRESHOLD = 10;
37673
37974
  function buildIssueFilter(config2) {
@@ -37676,16 +37977,17 @@ function buildIssueFilter(config2) {
37676
37977
  const repoFullName = extractRepoFromUrl(item.repository_url);
37677
37978
  if (!repoFullName)
37678
37979
  return false;
37679
- if (config2.excludedRepos.has(repoFullName))
37980
+ const repoLower = repoFullName.toLowerCase();
37981
+ if (config2.excludedRepos.has(repoLower))
37680
37982
  return false;
37681
37983
  if (config2.excludeOrgs.size > 0) {
37682
- const orgName = repoFullName.split("/")[0]?.toLowerCase();
37984
+ const orgName = repoLower.split("/")[0];
37683
37985
  if (orgName && config2.excludeOrgs.has(orgName))
37684
37986
  return false;
37685
37987
  }
37686
- if (config2.aiBlocklisted.has(repoFullName))
37988
+ if (config2.aiBlocklisted.has(repoLower))
37687
37989
  return false;
37688
- if (config2.lowScoringRepos.has(repoFullName))
37990
+ if (config2.lowScoringRepos.has(repoLower))
37689
37991
  return false;
37690
37992
  if (config2.skippedUrls.has(item.html_url))
37691
37993
  return false;
@@ -37700,9 +38002,9 @@ function buildIssueFilter(config2) {
37700
38002
  };
37701
38003
  }
37702
38004
  async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
37703
- 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)...`);
37704
38006
  const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, repos, [], maxResults, "merged_pr", filterIssues);
37705
- info(MODULE10, `Found ${candidates.length} candidates from merged-PR repos`);
38007
+ info(MODULE11, `Found ${candidates.length} candidates from merged-PR repos`);
37706
38008
  return {
37707
38009
  candidates,
37708
38010
  error: allReposFailed ? "All merged-PR repo fetches failed" : null,
@@ -37710,11 +38012,11 @@ async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
37710
38012
  };
37711
38013
  }
37712
38014
  async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssues) {
37713
- info(MODULE10, `Phase 1: Searching issues in ${repos.length} starred repos...`);
38015
+ info(MODULE11, `Phase 1: Searching issues in ${repos.length} starred repos...`);
37714
38016
  const phase1Labels = labels.slice(0, 3);
37715
38017
  const reposToSearch = repos.slice(0, 10);
37716
38018
  const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, reposToSearch, phase1Labels, maxResults, "starred", filterIssues);
37717
- info(MODULE10, `Found ${candidates.length} candidates from starred repos`);
38019
+ info(MODULE11, `Found ${candidates.length} candidates from starred repos`);
37718
38020
  return {
37719
38021
  candidates,
37720
38022
  error: allReposFailed ? "All starred repo fetches failed" : null,
@@ -37722,14 +38024,14 @@ async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssue
37722
38024
  };
37723
38025
  }
37724
38026
  async function runPhase2(octokit, vetter, scopes, labels, configLabels, languages, isAnyLanguage, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
37725
- info(MODULE10, "Phase 2: General issue search...");
38027
+ info(MODULE11, "Phase 2: General issue search...");
37726
38028
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
37727
38029
  const tierLabelGroups = [];
37728
38030
  if (scopes && scopes.length > 1) {
37729
38031
  for (const scope of scopes) {
37730
38032
  const scopeLabels = SCOPE_LABELS[scope] ?? [];
37731
38033
  if (scopeLabels.length === 0) {
37732
- warn(MODULE10, `Scope "${scope}" has no labels, skipping tier`);
38034
+ warn(MODULE11, `Scope "${scope}" has no labels, skipping tier`);
37733
38035
  continue;
37734
38036
  }
37735
38037
  tierLabelGroups.push({ tier: scope, tierLabels: scopeLabels });
@@ -37749,7 +38051,7 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37749
38051
  for (const { tier, tierLabels } of tierLabelGroups) {
37750
38052
  try {
37751
38053
  const allItems = await searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, tierLabels, (langQ) => `is:issue is:open ${langQ} no:assignee`.replace(/ +/g, " ").trim(), budgetPerTier * 3);
37752
- info(MODULE10, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
38054
+ info(MODULE11, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
37753
38055
  const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, allItems, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
37754
38056
  tierResults.push(tierCandidates);
37755
38057
  for (const c of tierCandidates)
@@ -37760,7 +38062,7 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37760
38062
  if (vetRateLimitHit) {
37761
38063
  rateLimitHit = true;
37762
38064
  }
37763
- info(MODULE10, `Found ${tierCandidates.length} candidates from ${tier} tier`);
38065
+ info(MODULE11, `Found ${tierCandidates.length} candidates from ${tier} tier`);
37764
38066
  } catch (err) {
37765
38067
  if (getHttpStatusCode(err) === 401)
37766
38068
  throw err;
@@ -37769,13 +38071,13 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37769
38071
  if (isRateLimitError(err)) {
37770
38072
  rateLimitHit = true;
37771
38073
  }
37772
- warn(MODULE10, `Error in ${tier} tier search: ${errMsg}`);
38074
+ warn(MODULE11, `Error in ${tier} tier search: ${errMsg}`);
37773
38075
  tierResults.push([]);
37774
38076
  }
37775
38077
  }
37776
38078
  const interleaved = interleaveArrays(tierResults);
37777
38079
  if (interleaved.length === 0 && error48) {
37778
- 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}`);
37779
38081
  }
37780
38082
  return {
37781
38083
  candidates: interleaved.slice(0, maxResults),
@@ -37784,17 +38086,17 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37784
38086
  };
37785
38087
  }
37786
38088
  async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, starredRepos, existingCandidates, filterIssues) {
37787
- info(MODULE10, "Phase 3: Searching actively maintained repos...");
38089
+ info(MODULE11, "Phase 3: Searching actively maintained repos...");
37788
38090
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
37789
38091
  const eligibleStarred = starredRepos.filter((r) => !phase0RepoSet.has(r) && !seenRepos.has(r));
37790
38092
  if (eligibleStarred.length > 0) {
37791
- 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...`);
37792
38094
  const restItems = await fetchIssuesFromMaintainedRepos(octokit, eligibleStarred.slice(0, 15), minStars, maxResults);
37793
38095
  if (restItems.length > 0) {
37794
38096
  try {
37795
38097
  const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, restItems, filterIssues, [phase0RepoSet, seenRepos], maxResults, minStars, "Phase 3 (REST)");
37796
38098
  if (candidates.length > 0) {
37797
- info(MODULE10, `Found ${candidates.length} candidates from maintained-repo REST search`);
38099
+ info(MODULE11, `Found ${candidates.length} candidates from maintained-repo REST search`);
37798
38100
  return {
37799
38101
  candidates,
37800
38102
  error: allVetFailed ? "all vetting failed" : null,
@@ -37804,11 +38106,11 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37804
38106
  } catch (error48) {
37805
38107
  if (getHttpStatusCode(error48) === 401)
37806
38108
  throw error48;
37807
- 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));
37808
38110
  }
37809
38111
  }
37810
38112
  }
37811
- info(MODULE10, "Phase 3: Falling back to Search API...");
38113
+ info(MODULE11, "Phase 3: Falling back to Search API...");
37812
38114
  const thirtyDaysAgo = /* @__PURE__ */ new Date();
37813
38115
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
37814
38116
  const pushedSince = thirtyDaysAgo.toISOString().split("T")[0];
@@ -37822,9 +38124,9 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37822
38124
  order: "desc",
37823
38125
  per_page: maxResults * 3
37824
38126
  });
37825
- 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}...`);
37826
38128
  const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], maxResults, minStars, "Phase 3");
37827
- info(MODULE10, `Found ${candidates.length} candidates from maintained-repo search`);
38129
+ info(MODULE11, `Found ${candidates.length} candidates from maintained-repo search`);
37828
38130
  return {
37829
38131
  candidates,
37830
38132
  error: allVetFailed ? "all vetting failed" : null,
@@ -37834,7 +38136,7 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37834
38136
  if (getHttpStatusCode(error48) === 401)
37835
38137
  throw error48;
37836
38138
  const errMsg = errorMessage(error48);
37837
- warn(MODULE10, `Error in maintained-repo search: ${errMsg}`);
38139
+ warn(MODULE11, `Error in maintained-repo search: ${errMsg}`);
37838
38140
  return {
37839
38141
  candidates: [],
37840
38142
  error: errMsg,
@@ -37900,9 +38202,10 @@ var IssueDiscovery = class {
37900
38202
  const labels = options.labels || (scopes ? buildEffectiveLabels(scopes, config2.labels) : config2.labels);
37901
38203
  const maxResults = options.maxResults || 10;
37902
38204
  const minStars = config2.minStars ?? 50;
37903
- const interPhaseDelay = config2.interPhaseDelayMs ?? 3e4;
38205
+ const interPhaseDelay = options.interPhaseDelayMs ?? config2.interPhaseDelayMs ?? 3e4;
37904
38206
  const ALL_STRATEGIES = CONCRETE_STRATEGIES;
37905
- 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);
37906
38209
  const enabledStrategies = new Set(rawStrategies.includes("all") ? ALL_STRATEGIES : rawStrategies);
37907
38210
  const strategiesUsed = [];
37908
38211
  const allCandidates = [];
@@ -37918,18 +38221,18 @@ var IssueDiscovery = class {
37918
38221
  if (rateLimit.remaining < 5) {
37919
38222
  const resetTime = new Date(rateLimit.resetAt).toLocaleTimeString("en-US", { hour12: false });
37920
38223
  this.rateLimitWarning = `GitHub search API quota low (${rateLimit.remaining}/${rateLimit.limit} remaining, resets at ${resetTime}). Search may be slow.`;
37921
- warn(MODULE10, this.rateLimitWarning);
38224
+ warn(MODULE11, this.rateLimitWarning);
37922
38225
  }
37923
38226
  if (searchBudget < CRITICAL_BUDGET_THRESHOLD) {
37924
- 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`);
37925
38228
  } else if (searchBudget < LOW_BUDGET_THRESHOLD) {
37926
- 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)`);
37927
38230
  }
37928
38231
  } catch (error48) {
37929
38232
  if (getHttpStatusCode(error48) === 401)
37930
38233
  throw error48;
37931
38234
  tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 6e4).toISOString());
37932
- 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));
37933
38236
  }
37934
38237
  if (searchBudget <= 0) {
37935
38238
  this.rateLimitWarning = "GitHub search API quota exhausted. Try again after the rate limit resets.";
@@ -37939,15 +38242,15 @@ var IssueDiscovery = class {
37939
38242
  const openPRRepos = this.stateReader.getReposWithOpenPRs();
37940
38243
  const starredRepos = this.getStarredRepos();
37941
38244
  const starredRepoSet = new Set(starredRepos);
37942
- const lowScoringRepos = new Set(this.deriveLowScoringRepos(config2.minRepoScoreThreshold));
38245
+ const lowScoringRepos = new Set(this.deriveLowScoringRepos(config2.minRepoScoreThreshold).map((r) => r.toLowerCase()));
37943
38246
  const isAnyLanguage = languages.some((l) => l.toLowerCase() === "any");
37944
38247
  const langQuery = isAnyLanguage ? "" : languages.map((l) => `language:${l}`).join(" ");
37945
- const aiBlocklisted = new Set(config2.aiPolicyBlocklist);
38248
+ const aiBlocklisted = new Set(config2.aiPolicyBlocklist.map((r) => r.toLowerCase()));
37946
38249
  if (aiBlocklisted.size > 0) {
37947
- 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(", ")}`);
37948
38251
  }
37949
38252
  const filterIssues = buildIssueFilter({
37950
- excludedRepos: new Set(config2.excludeRepos),
38253
+ excludedRepos: new Set(config2.excludeRepos.map((r) => r.toLowerCase())),
37951
38254
  excludeOrgs: new Set((config2.excludeOrgs ?? []).map((o) => o.toLowerCase())),
37952
38255
  aiBlocklisted,
37953
38256
  lowScoringRepos,
@@ -37980,7 +38283,7 @@ var IssueDiscovery = class {
37980
38283
  }
37981
38284
  if (allCandidates.length < maxResults && starredRepos.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD && enabledStrategies.has("starred")) {
37982
38285
  if (interPhaseDelay > 0) {
37983
- 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...`);
37984
38287
  await sleep(interPhaseDelay);
37985
38288
  }
37986
38289
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
@@ -37992,25 +38295,26 @@ var IssueDiscovery = class {
37992
38295
  phaseErrors["1"] = result.error;
37993
38296
  if (result.rateLimitHit)
37994
38297
  rateLimitHitDuringSearch = true;
38298
+ strategiesUsed.push("starred");
37995
38299
  }
37996
38300
  }
37997
- strategiesUsed.push("starred");
37998
38301
  }
37999
- const broadDelay = config2.broadPhaseDelayMs ?? 9e4;
38000
- 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;
38001
38305
  if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD && enabledStrategies.has("broad")) {
38002
38306
  if (skipThreshold > 0 && allCandidates.length >= skipThreshold) {
38003
- 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})`);
38004
38308
  } else {
38005
38309
  if (interPhaseDelay > 0) {
38006
- 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...`);
38007
38311
  await sleep(interPhaseDelay);
38008
38312
  }
38009
38313
  if (allCandidates.length > 0 && broadDelay > 0) {
38010
- 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...`);
38011
38315
  await sleep(broadDelay);
38012
38316
  } else if (allCandidates.length === 0) {
38013
- 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`);
38014
38318
  }
38015
38319
  const remaining = maxResults - allCandidates.length;
38016
38320
  const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config2.labels, languages, isAnyLanguage, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
@@ -38018,12 +38322,12 @@ var IssueDiscovery = class {
38018
38322
  phaseErrors["2"] = result.error;
38019
38323
  if (result.rateLimitHit)
38020
38324
  rateLimitHitDuringSearch = true;
38325
+ strategiesUsed.push("broad");
38021
38326
  }
38022
- strategiesUsed.push("broad");
38023
38327
  }
38024
38328
  if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD && enabledStrategies.has("maintained")) {
38025
38329
  if (interPhaseDelay > 0) {
38026
- 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...`);
38027
38331
  await sleep(interPhaseDelay);
38028
38332
  }
38029
38333
  const remaining = maxResults - allCandidates.length;
@@ -38058,8 +38362,8 @@ var IssueDiscovery = class {
38058
38362
  if (rateLimitHitDuringSearch || phasesSkippedForBudget) {
38059
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.`;
38060
38364
  }
38061
- annotateBoost(allCandidates, options.preferLanguages, options.preferRepos);
38062
- allCandidates.sort((a, b) => {
38365
+ const ranked = annotateBoost(allCandidates, options.preferLanguages, options.preferRepos);
38366
+ ranked.sort((a, b) => {
38063
38367
  const priorityOrder = {
38064
38368
  merged_pr: 0,
38065
38369
  starred: 1,
@@ -38072,14 +38376,14 @@ var IssueDiscovery = class {
38072
38376
  const recDiff = recommendationOrder[a.recommendation] - recommendationOrder[b.recommendation];
38073
38377
  if (recDiff !== 0)
38074
38378
  return recDiff;
38075
- const boostDiff = (b.boostScore ?? 0) - (a.boostScore ?? 0);
38379
+ const boostDiff = boostScoreOf(b) - boostScoreOf(a);
38076
38380
  if (boostDiff !== 0)
38077
38381
  return boostDiff;
38078
38382
  return b.viabilityScore - a.viabilityScore;
38079
38383
  });
38080
- const capped = applyPerRepoCap(allCandidates, 2);
38384
+ const capped = applyPerRepoCap(ranked, 2);
38081
38385
  const finalPicks = applyDiversityRatio(capped, maxResults, options.diversityRatio ?? 0);
38082
- info(MODULE10, `Search complete: ${tracker.getTotalCalls()} Search API calls used, ${finalPicks.length} candidates returned`);
38386
+ info(MODULE11, `Search complete: ${tracker.getTotalCalls()} Search API calls used, ${finalPicks.length} candidates returned`);
38083
38387
  return { candidates: finalPicks, strategiesUsed };
38084
38388
  }
38085
38389
  /**
@@ -38112,7 +38416,7 @@ var IssueDiscovery = class {
38112
38416
  };
38113
38417
 
38114
38418
  // ../core/dist/core/roadmap.js
38115
- var MODULE11 = "roadmap";
38419
+ var MODULE12 = "roadmap";
38116
38420
  var CACHE_TTL_MS2 = 60 * 60 * 1e3;
38117
38421
  var ROADMAP_PATHS = [
38118
38422
  "ROADMAP.md",
@@ -38166,6 +38470,19 @@ async function fetchRoadmapIssueRefs(octokit, owner, repo) {
38166
38470
  if (cached2 && Date.now() - cached2.fetchedAt < CACHE_TTL_MS2) {
38167
38471
  return cached2.refs;
38168
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) {
38169
38486
  for (const path5 of ROADMAP_PATHS) {
38170
38487
  try {
38171
38488
  const { data } = await octokit.repos.getContent({ owner, repo, path: path5 });
@@ -38177,12 +38494,11 @@ async function fetchRoadmapIssueRefs(octokit, owner, repo) {
38177
38494
  pruneCache2();
38178
38495
  return refs;
38179
38496
  } catch (err) {
38180
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38181
- throw err;
38497
+ rethrowIfFatal(err);
38182
38498
  const status = getHttpStatusCode(err);
38183
38499
  if (status === 404)
38184
38500
  continue;
38185
- warn(MODULE11, `Unexpected error fetching ${path5} from ${owner}/${repo}: ${errorMessage(err)}`);
38501
+ warn(MODULE12, `Unexpected error fetching ${path5} from ${owner}/${repo}: ${errorMessage(err)}`);
38186
38502
  }
38187
38503
  }
38188
38504
  const empty = /* @__PURE__ */ new Set();
@@ -38192,7 +38508,7 @@ async function fetchRoadmapIssueRefs(octokit, owner, repo) {
38192
38508
  }
38193
38509
 
38194
38510
  // ../core/dist/core/feature-discovery.js
38195
- var MODULE12 = "feature-discovery";
38511
+ var MODULE13 = "feature-discovery";
38196
38512
  var INTER_REPO_DELAY_MS = 2e3;
38197
38513
  var MIN_VIABILITY_SCORE = 40;
38198
38514
  var ANCHOR_THRESHOLD = 3;
@@ -38284,6 +38600,37 @@ function isFeatureIssue(item) {
38284
38600
  return false;
38285
38601
  return labels.some((l) => FEATURE_LABELS.includes(l));
38286
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
+ }
38287
38634
  async function discoverFeatures(opts) {
38288
38635
  const anchorRepos = resolveAnchorRepos(opts.repoScores, opts.anchorThreshold);
38289
38636
  if (anchorRepos.length === 0) {
@@ -38316,42 +38663,15 @@ async function discoverFeatures(opts) {
38316
38663
  response = listResp;
38317
38664
  roadmapRefs = refs;
38318
38665
  } catch (err) {
38319
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38320
- throw err;
38321
- 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)}`);
38322
38668
  continue;
38323
38669
  }
38324
38670
  const items = response.data.filter((it) => !it.pull_request && !it.assignee && isFeatureIssue(it));
38325
38671
  for (const item of items) {
38326
- const labels = extractLabels(item);
38327
- const hasMilestone = !!item.milestone;
38328
- const reactions = item.reactions?.total_count ?? 0;
38329
- const comments = item.comments ?? 0;
38330
- const wontfixNoContributor = item.created_at ? detectWontfixNoContributor({ labels, createdAt: item.created_at }) : false;
38331
- const onRoadmap = typeof item.number === "number" && roadmapRefs.has(item.number);
38332
- let candidate;
38333
- try {
38334
- candidate = await opts.vetter.vetIssue(item.html_url, {
38335
- featureSignals: {
38336
- reactions,
38337
- comments,
38338
- hasMilestone,
38339
- wontfixNoContributor,
38340
- onRoadmap
38341
- }
38342
- });
38343
- } catch (err) {
38344
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38345
- throw err;
38346
- warn(MODULE12, `vet failed for ${item.html_url}: ${errorMessage(err)}`);
38347
- continue;
38348
- }
38349
- const horizon = classifyHorizon({
38350
- hasMilestone,
38351
- labels,
38352
- isOnRoadmap: onRoadmap
38353
- });
38354
- candidates.push({ ...candidate, horizon });
38672
+ const candidate = await vetAndClassify(item, opts.vetter, roadmapRefs);
38673
+ if (candidate)
38674
+ candidates.push(candidate);
38355
38675
  }
38356
38676
  }
38357
38677
  const passing = candidates.filter((c) => c.viabilityScore >= MIN_VIABILITY_SCORE);
@@ -38372,10 +38692,8 @@ function buildBroadFeatureSearchQuery(opts) {
38372
38692
  for (const excl of FEATURE_EXCLUSION_LABELS) {
38373
38693
  parts.push(`-label:"${excl}"`);
38374
38694
  }
38375
- const languages = (opts.languages ?? []).filter((l) => l && l.toLowerCase() !== "any");
38376
- if (languages.length > 0) {
38377
- const langClause = languages.map((l) => `language:${l}`).join(" OR ");
38378
- parts.push(`(${langClause})`);
38695
+ if (opts.language) {
38696
+ parts.push(`language:${opts.language}`);
38379
38697
  }
38380
38698
  for (const repo of opts.excludeRepos ?? []) {
38381
38699
  parts.push(`-repo:${repo}`);
@@ -38385,8 +38703,19 @@ function buildBroadFeatureSearchQuery(opts) {
38385
38703
  }
38386
38704
  return parts.join(" ");
38387
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
+ }
38388
38717
  async function discoverFeaturesBroad(opts) {
38389
- const query = buildBroadFeatureSearchQuery({
38718
+ const queries = buildBroadFeatureSearchQueries({
38390
38719
  languages: opts.languages,
38391
38720
  excludeRepos: opts.excludeRepos,
38392
38721
  excludeOrgs: opts.excludeOrgs
@@ -38394,17 +38723,26 @@ async function discoverFeaturesBroad(opts) {
38394
38723
  const maxToVet = opts.maxToVet ?? DEFAULT_BROAD_MAX_TO_VET;
38395
38724
  let items;
38396
38725
  try {
38397
- const response = await opts.octokit.search.issuesAndPullRequests({
38398
- q: query,
38399
- sort: "interactions",
38400
- order: "desc",
38401
- per_page: maxToVet
38402
- });
38403
- 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);
38404
38743
  } catch (err) {
38405
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38406
- throw err;
38407
- warn(MODULE12, `broad feature search failed: ${errorMessage(err)}`);
38744
+ rethrowIfFatal(err);
38745
+ warn(MODULE13, `broad feature search failed: ${errorMessage(err)}`);
38408
38746
  return {
38409
38747
  quickWins: [],
38410
38748
  biggerBets: [],
@@ -38414,32 +38752,9 @@ async function discoverFeaturesBroad(opts) {
38414
38752
  }
38415
38753
  const candidates = [];
38416
38754
  for (const item of items) {
38417
- const labels = extractLabels(item);
38418
- const hasMilestone = !!item.milestone;
38419
- const reactions = item.reactions?.total_count ?? 0;
38420
- const comments = item.comments ?? 0;
38421
- const wontfixNoContributor = item.created_at ? detectWontfixNoContributor({ labels, createdAt: item.created_at }) : false;
38422
- let candidate;
38423
- try {
38424
- candidate = await opts.vetter.vetIssue(item.html_url, {
38425
- featureSignals: {
38426
- reactions,
38427
- comments,
38428
- hasMilestone,
38429
- wontfixNoContributor
38430
- // Roadmap scraping is per-repo and would require an extra fetch
38431
- // per unique repo in the broad result set — deliberately skipped
38432
- // here to keep the broad path cheap. Anchor mode keeps the bonus.
38433
- }
38434
- });
38435
- } catch (err) {
38436
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38437
- throw err;
38438
- warn(MODULE12, `vet failed for ${item.html_url}: ${errorMessage(err)}`);
38439
- continue;
38440
- }
38441
- const horizon = classifyHorizon({ hasMilestone, labels });
38442
- candidates.push({ ...candidate, horizon });
38755
+ const candidate = await vetAndClassify(item, opts.vetter);
38756
+ if (candidate)
38757
+ candidates.push(candidate);
38443
38758
  }
38444
38759
  const passing = candidates.filter((c) => c.viabilityScore >= MIN_VIABILITY_SCORE);
38445
38760
  const split = splitByHorizon(passing, opts.count, opts.splitRatio);
@@ -38454,19 +38769,16 @@ async function discoverFeaturesBroad(opts) {
38454
38769
  // ../core/dist/core/gist-state-store.js
38455
38770
  var fs3 = __toESM(require("fs"), 1);
38456
38771
  var path3 = __toESM(require("path"), 1);
38457
- var MODULE13 = "gist-state";
38772
+ var MODULE14 = "gist-state";
38458
38773
  var GIST_DESCRIPTION = "oss-scout-state";
38459
38774
  var GIST_FILENAME = "state.json";
38460
38775
  var GIST_ID_FILE = "gist-id";
38461
38776
  var CACHE_FILE = "state-cache.json";
38462
- var SEARCH_MAX_PAGES = 5;
38777
+ var SEARCH_MAX_PAGES = 10;
38463
38778
  function classifyDegradedReason(err) {
38464
38779
  if (isRateLimitError(err))
38465
38780
  return "rate_limit";
38466
38781
  const status = getHttpStatusCode(err);
38467
- if (status === 403 && errorMessage(err).toLowerCase().includes("abuse detection")) {
38468
- return "rate_limit";
38469
- }
38470
38782
  if (status !== void 0 && status >= 500 && status < 600)
38471
38783
  return "server";
38472
38784
  if (err && typeof err === "object" && "code" in err) {
@@ -38502,7 +38814,7 @@ var GistStateStore = class {
38502
38814
  } catch (err) {
38503
38815
  if (getHttpStatusCode(err) === 401)
38504
38816
  throw err;
38505
- warn(MODULE13, `API bootstrap failed: ${errorMessage(err)}`);
38817
+ warn(MODULE14, `API bootstrap failed: ${errorMessage(err)}`);
38506
38818
  return this.bootstrapFromCache(classifyDegradedReason(err));
38507
38819
  }
38508
38820
  }
@@ -38510,14 +38822,25 @@ var GistStateStore = class {
38510
38822
  * Push state to the gist. Also writes to local cache as fallback.
38511
38823
  */
38512
38824
  async push(state) {
38513
- this.writeCache(state);
38514
38825
  if (!this.gistId) {
38515
- warn(MODULE13, "No gist ID \u2014 cannot push");
38826
+ warn(MODULE14, "No gist ID \u2014 cannot push");
38827
+ this.writeCache(state);
38516
38828
  return false;
38517
38829
  }
38518
- 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);
38519
38842
  if (json2.length > 9e5) {
38520
- 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'.`);
38521
38844
  return false;
38522
38845
  }
38523
38846
  try {
@@ -38527,34 +38850,14 @@ var GistStateStore = class {
38527
38850
  [GIST_FILENAME]: { content: json2 }
38528
38851
  }
38529
38852
  });
38530
- debug(MODULE13, "State pushed to gist");
38853
+ debug(MODULE14, "State pushed to gist");
38531
38854
  return true;
38532
38855
  } catch (err) {
38533
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38534
- throw err;
38535
- warn(MODULE13, `Failed to push: ${errorMessage(err)}`);
38856
+ rethrowIfFatal(err);
38857
+ warn(MODULE14, `Failed to push: ${errorMessage(err)}`);
38536
38858
  return false;
38537
38859
  }
38538
38860
  }
38539
- /**
38540
- * Pull state from the gist and merge with local state.
38541
- */
38542
- async pull() {
38543
- if (!this.gistId)
38544
- return null;
38545
- try {
38546
- const state = await this.fetchGistState(this.gistId);
38547
- if (state) {
38548
- this.writeCache(state);
38549
- }
38550
- return state;
38551
- } catch (err) {
38552
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38553
- throw err;
38554
- warn(MODULE13, `Failed to pull: ${errorMessage(err)}`);
38555
- return null;
38556
- }
38557
- }
38558
38861
  /** Get the current gist ID (if known). */
38559
38862
  getGistId() {
38560
38863
  return this.gistId;
@@ -38563,35 +38866,40 @@ var GistStateStore = class {
38563
38866
  async bootstrapFromApi() {
38564
38867
  const cachedId = this.readCachedGistId();
38565
38868
  if (cachedId) {
38566
- debug(MODULE13, `Trying cached gist ID: ${cachedId}`);
38869
+ debug(MODULE14, `Trying cached gist ID: ${cachedId}`);
38567
38870
  try {
38568
- const state = await this.fetchGistState(cachedId);
38569
- if (state) {
38871
+ const fetched = await this.fetchGistState(cachedId);
38872
+ if (fetched) {
38570
38873
  this.gistId = cachedId;
38874
+ const state = this.mergeCacheInto(fetched);
38571
38875
  this.writeCache(state);
38572
38876
  return { gistId: cachedId, state, created: false };
38573
38877
  }
38574
38878
  } catch (err) {
38575
38879
  if (getHttpStatusCode(err) !== 404)
38576
38880
  throw err;
38577
- debug(MODULE13, `Cached gist gone (404): ${errorMessage(err)}`);
38578
- }
38579
- debug(MODULE13, "Cached gist ID invalid, searching...");
38580
- }
38581
- const foundId = await this.searchForGist();
38582
- if (foundId) {
38583
- debug(MODULE13, `Found gist via search: ${foundId}`);
38584
- this.saveGistId(foundId);
38585
- this.gistId = foundId;
38586
- const state = await this.fetchGistState(foundId);
38587
- 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);
38588
38893
  this.writeCache(state);
38589
- return { gistId: foundId, state, created: false };
38894
+ return { gistId: search.id, state, created: false };
38590
38895
  }
38591
- 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.`);
38592
38897
  return this.bootstrapFromCache("unknown");
38593
38898
  }
38594
- 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");
38595
38903
  const freshState = ScoutStateSchema.parse({ version: 1 });
38596
38904
  const newId = await this.createGist(freshState);
38597
38905
  this.saveGistId(newId);
@@ -38602,7 +38910,7 @@ var GistStateStore = class {
38602
38910
  bootstrapFromCache(reason) {
38603
38911
  const cached2 = this.readCache();
38604
38912
  if (cached2) {
38605
- debug(MODULE13, "Bootstrapped from local cache (degraded mode)");
38913
+ debug(MODULE14, "Bootstrapped from local cache (degraded mode)");
38606
38914
  const cachedId = this.readCachedGistId();
38607
38915
  if (cachedId)
38608
38916
  this.gistId = cachedId;
@@ -38614,7 +38922,7 @@ var GistStateStore = class {
38614
38922
  degradedReason: reason
38615
38923
  };
38616
38924
  }
38617
- debug(MODULE13, "No cache available, using fresh state (degraded mode)");
38925
+ debug(MODULE14, "No cache available, using fresh state (degraded mode)");
38618
38926
  const fresh = ScoutStateSchema.parse({ version: 1 });
38619
38927
  return {
38620
38928
  gistId: "",
@@ -38632,12 +38940,17 @@ var GistStateStore = class {
38632
38940
  return null;
38633
38941
  try {
38634
38942
  const parsed = JSON.parse(file2.content);
38635
- return ScoutStateSchema.parse(parsed);
38943
+ return parseScoutState(parsed);
38636
38944
  } catch (err) {
38637
- warn(MODULE13, `Gist content failed validation: ${errorMessage(err)}`);
38945
+ warn(MODULE14, `Gist content failed validation: ${errorMessage(err)}`);
38638
38946
  return null;
38639
38947
  }
38640
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
+ */
38641
38954
  async searchForGist() {
38642
38955
  for (let page = 1; page <= SEARCH_MAX_PAGES; page++) {
38643
38956
  const { data: gists } = await this.octokit.gists.list({
@@ -38645,12 +38958,14 @@ var GistStateStore = class {
38645
38958
  page
38646
38959
  });
38647
38960
  if (gists.length === 0)
38648
- break;
38961
+ return { id: null, exhaustive: true };
38649
38962
  const match = gists.find((g) => g.description === GIST_DESCRIPTION);
38650
38963
  if (match)
38651
- return match.id;
38964
+ return { id: match.id, exhaustive: true };
38965
+ if (gists.length < 100)
38966
+ return { id: null, exhaustive: true };
38652
38967
  }
38653
- return null;
38968
+ return { id: null, exhaustive: false };
38654
38969
  }
38655
38970
  async createGist(state) {
38656
38971
  const { data } = await this.octokit.gists.create({
@@ -38670,7 +38985,7 @@ var GistStateStore = class {
38670
38985
  } catch (err) {
38671
38986
  const code = err?.code;
38672
38987
  if (code !== "ENOENT") {
38673
- warn(MODULE13, `Failed to read cached gist ID: ${errorMessage(err)}`);
38988
+ warn(MODULE14, `Failed to read cached gist ID: ${errorMessage(err)}`);
38674
38989
  }
38675
38990
  return null;
38676
38991
  }
@@ -38678,14 +38993,25 @@ var GistStateStore = class {
38678
38993
  saveGistId(id) {
38679
38994
  fs3.writeFileSync(getGistIdPath(), id + "\n", { mode: 384 });
38680
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
+ }
38681
39007
  readCache() {
38682
39008
  try {
38683
39009
  const raw = fs3.readFileSync(getCachePath(), "utf-8");
38684
- return ScoutStateSchema.parse(JSON.parse(raw));
39010
+ return parseScoutState(JSON.parse(raw));
38685
39011
  } catch (err) {
38686
39012
  const code = err?.code;
38687
39013
  if (code !== "ENOENT") {
38688
- warn(MODULE13, `Failed to read state cache: ${errorMessage(err)}`);
39014
+ warn(MODULE14, `Failed to read state cache: ${errorMessage(err)}`);
38689
39015
  }
38690
39016
  return null;
38691
39017
  }
@@ -38696,22 +39022,61 @@ var GistStateStore = class {
38696
39022
  mode: 384
38697
39023
  });
38698
39024
  } catch (err) {
38699
- warn(MODULE13, `Failed to write cache: ${errorMessage(err)}`);
39025
+ warn(MODULE14, `Failed to write cache: ${errorMessage(err)}`);
38700
39026
  }
38701
39027
  }
38702
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
+ }
38703
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);
38704
39065
  return {
39066
+ ...local,
39067
+ ...remote,
38705
39068
  version: 1,
38706
- preferences: remote.preferences,
39069
+ preferences,
39070
+ preferencesUpdatedAt,
39071
+ tombstones,
38707
39072
  repoScores: mergeRepoScores(local.repoScores, remote.repoScores),
38708
39073
  starredRepos: mergeStarredRepos(local, remote),
38709
39074
  starredReposLastFetched: pickFresherTimestamp(local.starredReposLastFetched, remote.starredReposLastFetched),
38710
39075
  mergedPRs: unionByUrl(local.mergedPRs, remote.mergedPRs),
38711
39076
  closedPRs: unionByUrl(local.closedPRs, remote.closedPRs),
38712
39077
  openPRs: unionByUrl(local.openPRs ?? [], remote.openPRs ?? []),
38713
- savedResults: mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []),
38714
- skippedIssues: mergeSkippedIssues(local.skippedIssues ?? [], remote.skippedIssues ?? []),
39078
+ savedResults: savedResultsFinal,
39079
+ skippedIssues,
38715
39080
  lastSearchAt: pickFresherTimestamp(local.lastSearchAt, remote.lastSearchAt),
38716
39081
  lastRunAt: pickFresherTimestamp(local.lastRunAt, remote.lastRunAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
38717
39082
  gistId: remote.gistId ?? local.gistId
@@ -38785,7 +39150,7 @@ function pickFresherTimestamp(a, b) {
38785
39150
  // ../core/dist/core/local-state.js
38786
39151
  var fs4 = __toESM(require("fs"), 1);
38787
39152
  var path4 = __toESM(require("path"), 1);
38788
- var MODULE14 = "local-state";
39153
+ var MODULE15 = "local-state";
38789
39154
  function getStatePath() {
38790
39155
  return path4.join(getDataDir(), "state.json");
38791
39156
  }
@@ -38793,23 +39158,40 @@ function loadLocalState() {
38793
39158
  const statePath = getStatePath();
38794
39159
  try {
38795
39160
  const raw = fs4.readFileSync(statePath, "utf-8");
38796
- return ScoutStateSchema.parse(JSON.parse(raw));
39161
+ return parseScoutState(JSON.parse(raw));
38797
39162
  } catch (err) {
38798
39163
  const code = err?.code;
38799
39164
  if (code === "ENOENT") {
38800
39165
  return ScoutStateSchema.parse({ version: 1 });
38801
39166
  }
38802
- 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.`);
38803
39168
  try {
38804
39169
  const backupPath = `${statePath}.corrupt.${Date.now()}`;
38805
39170
  fs4.copyFileSync(statePath, backupPath);
38806
- warn(MODULE14, `Corrupt state backed up to ${backupPath}`);
39171
+ warn(MODULE15, `Corrupt state backed up to ${backupPath}`);
38807
39172
  } catch (backupErr) {
38808
- warn(MODULE14, `Failed to back up corrupt state: ${errorMessage(backupErr)}`);
39173
+ warn(MODULE15, `Failed to back up corrupt state: ${errorMessage(backupErr)}`);
38809
39174
  }
38810
39175
  return ScoutStateSchema.parse({ version: 1 });
38811
39176
  }
38812
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
+ }
38813
39195
 
38814
39196
  // ../core/dist/scout.js
38815
39197
  function offlineModeMessage(reason) {
@@ -38864,8 +39246,12 @@ function toGistOctokit(octokit) {
38864
39246
  };
38865
39247
  }
38866
39248
  async function createScout(config2) {
39249
+ if (config2.logLevel !== void 0) {
39250
+ setLogLevel(config2.logLevel);
39251
+ }
38867
39252
  let state;
38868
39253
  let gistStore = null;
39254
+ let persistLocal = false;
38869
39255
  if (config2.persistence === "provided") {
38870
39256
  state = config2.initialState;
38871
39257
  } else if (config2.persistence === "gist") {
@@ -38882,37 +39268,61 @@ async function createScout(config2) {
38882
39268
  state.gistId = result.gistId;
38883
39269
  }
38884
39270
  } else {
38885
- state = ScoutStateSchema.parse({ version: 1 });
39271
+ state = loadLocalState();
39272
+ persistLocal = true;
38886
39273
  }
38887
- return new OssScout(config2.githubToken, state, gistStore);
39274
+ return new OssScout(config2.githubToken, state, gistStore, { persistLocal });
38888
39275
  }
38889
39276
  var OssScout = class {
38890
39277
  githubToken;
38891
39278
  gistStore;
38892
39279
  state;
38893
39280
  dirty = false;
38894
- constructor(githubToken, initialState, gistStore = null) {
39281
+ /** When true, checkpoint() also writes ~/.oss-scout/state.json. */
39282
+ persistLocal;
39283
+ constructor(githubToken, initialState, gistStore = null, opts = {}) {
38895
39284
  this.githubToken = githubToken;
38896
39285
  this.gistStore = gistStore;
38897
39286
  this.state = initialState;
39287
+ this.persistLocal = opts.persistLocal ?? false;
38898
39288
  }
38899
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
+ }
38900
39298
  /**
38901
39299
  * Multi-strategy issue search. Returns scored, sorted candidates.
38902
39300
  * Automatically culls expired skip entries and filters skipped issues.
38903
39301
  */
38904
39302
  async search(options) {
39303
+ this.evictStaleCacheEntries();
38905
39304
  this.cullExpiredSkips();
38906
39305
  const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
38907
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;
38908
39313
  const { candidates, strategiesUsed } = await discovery.searchIssues({
38909
39314
  maxResults: options?.maxResults,
38910
39315
  strategies: options?.strategies,
38911
39316
  skippedUrls,
38912
- preferLanguages: options?.preferLanguages,
38913
- preferRepos: options?.preferRepos,
38914
- diversityRatio: options?.diversityRatio
39317
+ preferLanguages,
39318
+ preferRepos,
39319
+ diversityRatio,
39320
+ interPhaseDelayMs: options?.interPhaseDelayMs,
39321
+ broadPhaseDelayMs: options?.broadPhaseDelayMs
38915
39322
  });
39323
+ for (const c of candidates) {
39324
+ this.updateRepoSignalsFromHealth(c.projectHealth);
39325
+ }
38916
39326
  this.state.lastSearchAt = (/* @__PURE__ */ new Date()).toISOString();
38917
39327
  this.dirty = true;
38918
39328
  return {
@@ -38923,6 +39333,28 @@ var OssScout = class {
38923
39333
  strategiesUsed
38924
39334
  };
38925
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
+ }
38926
39358
  /**
38927
39359
  * Vet a single issue URL for claimability.
38928
39360
  */
@@ -38945,6 +39377,7 @@ var OssScout = class {
38945
39377
  * preferences and excluded repos/orgs.
38946
39378
  */
38947
39379
  async features(options) {
39380
+ this.evictStaleCacheEntries();
38948
39381
  const count = options?.count ?? 10;
38949
39382
  const octokit = getOctokit(this.githubToken);
38950
39383
  const vetter = new IssueVetter(octokit, this);
@@ -38976,6 +39409,7 @@ var OssScout = class {
38976
39409
  * Optionally prunes unavailable issues from saved results.
38977
39410
  */
38978
39411
  async vetList(options) {
39412
+ this.evictStaleCacheEntries();
38979
39413
  const saved = this.getSavedResults();
38980
39414
  const concurrency = options?.concurrency ?? 5;
38981
39415
  const results = [];
@@ -38991,6 +39425,7 @@ var OssScout = class {
38991
39425
  number: item.number,
38992
39426
  title: item.title,
38993
39427
  status: this.classifyVetResult(candidate),
39428
+ ok: true,
38994
39429
  recommendation: candidate.recommendation,
38995
39430
  viabilityScore: candidate.viabilityScore
38996
39431
  });
@@ -39007,6 +39442,7 @@ var OssScout = class {
39007
39442
  number: item.number,
39008
39443
  title: item.title,
39009
39444
  status: isGone ? "closed" : "error",
39445
+ ok: false,
39010
39446
  errorMessage: errorMessage(error48)
39011
39447
  });
39012
39448
  }).finally(() => {
@@ -39032,17 +39468,41 @@ var OssScout = class {
39032
39468
  hasPR: results.filter((r) => r.status === "has_pr").length,
39033
39469
  errors: results.filter((r) => r.status === "error").length
39034
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;
39035
39491
  let prunedCount;
39036
39492
  if (options?.prune) {
39037
39493
  const unavailableUrls = new Set(results.filter((r) => r.status !== "still_available").map((r) => r.issueUrl));
39038
39494
  const before = (this.state.savedResults ?? []).length;
39039
39495
  this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
39040
39496
  prunedCount = before - (this.state.savedResults?.length ?? 0);
39497
+ if (prunedCount > 0)
39498
+ this.addTombstones([...unavailableUrls]);
39041
39499
  this.dirty = true;
39042
39500
  }
39043
- return { results, summary, prunedCount };
39501
+ return { results, summary, prunedCount, transitions };
39044
39502
  }
39045
39503
  classifyVetResult(candidate) {
39504
+ if (candidate.issueState === "closed")
39505
+ return "closed";
39046
39506
  const checks = candidate.vettingResult.checks;
39047
39507
  if (!checks.noExistingPR)
39048
39508
  return "has_pr";
@@ -39077,19 +39537,35 @@ var OssScout = class {
39077
39537
  getProjectCategories() {
39078
39538
  return this.state.preferences.projectCategories;
39079
39539
  }
39540
+ /** Configured GitHub username (used to classify own vs competing PRs, #166). */
39541
+ getGitHubUsername() {
39542
+ return this.state.preferences.githubUsername;
39543
+ }
39080
39544
  getRepoScore(repo) {
39081
39545
  const score = this.state.repoScores[repo];
39082
39546
  return score ? score.score : null;
39083
39547
  }
39084
39548
  /**
39085
- * Optional SLM pre-triage config read from preferences (oss-autopilot#1122).
39086
- * 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).
39087
39563
  */
39088
39564
  getSLMTriageConfig() {
39089
- return {
39090
- model: this.state.preferences.slmTriageModel ?? "",
39091
- host: this.state.preferences.slmTriageHost ?? ""
39092
- };
39565
+ const model = this.state.preferences.slmTriageModel ?? "";
39566
+ if (!model)
39567
+ return null;
39568
+ return { model, host: this.state.preferences.slmTriageHost ?? "" };
39093
39569
  }
39094
39570
  /** Get current preferences (read-only). */
39095
39571
  getPreferences() {
@@ -39143,6 +39619,73 @@ var OssScout = class {
39143
39619
  ];
39144
39620
  this.dirty = true;
39145
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
+ }
39146
39689
  /**
39147
39690
  * Update repo score with observed signals.
39148
39691
  */
@@ -39177,6 +39720,7 @@ var OssScout = class {
39177
39720
  */
39178
39721
  updatePreferences(updates) {
39179
39722
  this.state.preferences = { ...this.state.preferences, ...updates };
39723
+ this.state.preferencesUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
39180
39724
  this.dirty = true;
39181
39725
  }
39182
39726
  /**
@@ -39226,9 +39770,25 @@ var OssScout = class {
39226
39770
  * Clear all saved results.
39227
39771
  */
39228
39772
  clearResults() {
39773
+ this.addTombstones((this.state.savedResults ?? []).map((r) => r.issueUrl));
39229
39774
  this.state.savedResults = [];
39230
39775
  this.dirty = true;
39231
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
+ }
39232
39792
  // ── Skip List ───────────────────────────────────────────────────────
39233
39793
  /**
39234
39794
  * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
@@ -39262,13 +39822,17 @@ var OssScout = class {
39262
39822
  * Remove a specific issue from the skip list.
39263
39823
  */
39264
39824
  unskipIssue(url2) {
39825
+ const before = (this.state.skippedIssues ?? []).length;
39265
39826
  this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url2);
39827
+ if (this.state.skippedIssues.length < before)
39828
+ this.addTombstones([url2]);
39266
39829
  this.dirty = true;
39267
39830
  }
39268
39831
  /**
39269
39832
  * Clear all skipped issues.
39270
39833
  */
39271
39834
  clearSkippedIssues() {
39835
+ this.addTombstones((this.state.skippedIssues ?? []).map((s) => s.url));
39272
39836
  this.state.skippedIssues = [];
39273
39837
  this.dirty = true;
39274
39838
  }
@@ -39312,6 +39876,13 @@ var OssScout = class {
39312
39876
  if (!ok)
39313
39877
  return false;
39314
39878
  }
39879
+ if (this.persistLocal) {
39880
+ try {
39881
+ saveLocalState(this.state);
39882
+ } catch {
39883
+ return false;
39884
+ }
39885
+ }
39315
39886
  this.dirty = false;
39316
39887
  return true;
39317
39888
  }
@@ -39350,8 +39921,152 @@ var OssScout = class {
39350
39921
  }
39351
39922
  };
39352
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
+
39353
40068
  // src/tools.ts
39354
- var TOOL_TIMEOUT_MS = 6e4;
40069
+ var TOOL_TIMEOUT_MS = 3e5;
39355
40070
  function withTimeout(promise2, ms = TOOL_TIMEOUT_MS) {
39356
40071
  let timeoutId;
39357
40072
  const timer = new Promise((_, reject) => {
@@ -39396,7 +40111,11 @@ function registerTools(server, scout) {
39396
40111
  strategies: parsedStrategies,
39397
40112
  preferLanguages,
39398
40113
  preferRepos,
39399
- diversityRatio
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
39400
40119
  })
39401
40120
  );
39402
40121
  scout.saveResults(result.candidates);
@@ -39477,6 +40196,24 @@ function registerTools(server, scout) {
39477
40196
  };
39478
40197
  }
39479
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
+ );
39480
40217
  server.tool(
39481
40218
  "skip",
39482
40219
  "Skip, unskip, list, or clear skipped issues. Skipped issues are excluded from future searches and auto-expire after 90 days.",
@@ -39507,7 +40244,7 @@ function registerTools(server, scout) {
39507
40244
  const count = scout.getSkippedIssues().length;
39508
40245
  scout.clearSkippedIssues();
39509
40246
  const synced2 = await scout.checkpoint();
39510
- const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
40247
+ const syncNote2 = synced2 ? "" : " (warning: failed to persist to disk)";
39511
40248
  return {
39512
40249
  content: [
39513
40250
  {
@@ -39521,7 +40258,7 @@ function registerTools(server, scout) {
39521
40258
  const wasPresent = scout.getSkippedIssues().some((s) => s.url === issueUrl);
39522
40259
  scout.unskipIssue(issueUrl);
39523
40260
  const synced2 = await scout.checkpoint();
39524
- const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
40261
+ const syncNote2 = synced2 ? "" : " (warning: failed to persist to disk)";
39525
40262
  return {
39526
40263
  content: [
39527
40264
  {
@@ -39531,6 +40268,8 @@ function registerTools(server, scout) {
39531
40268
  ]
39532
40269
  };
39533
40270
  }
40271
+ validateUrl(issueUrl);
40272
+ validateGitHubUrl(issueUrl, ISSUE_URL_PATTERN, "issue");
39534
40273
  const alreadySkipped = scout.getSkippedIssues().some((s) => s.url === issueUrl);
39535
40274
  if (alreadySkipped) {
39536
40275
  return {
@@ -39547,7 +40286,7 @@ function registerTools(server, scout) {
39547
40286
  } : void 0;
39548
40287
  scout.skipIssue(issueUrl, metadata);
39549
40288
  const synced = await scout.checkpoint();
39550
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
40289
+ const syncNote = synced ? "" : " (warning: failed to persist to disk)";
39551
40290
  return {
39552
40291
  content: [{ type: "text", text: `Skipped: ${issueUrl}${syncNote}` }]
39553
40292
  };
@@ -39560,101 +40299,31 @@ function registerTools(server, scout) {
39560
40299
  }
39561
40300
  }
39562
40301
  );
39563
- const VALID_KEYS = Object.keys(ScoutPreferencesSchema.shape);
39564
- const ARRAY_KEYS = /* @__PURE__ */ new Set([
39565
- "languages",
39566
- "labels",
39567
- "projectCategories",
39568
- "excludeRepos",
39569
- "excludeOrgs",
39570
- "aiPolicyBlocklist",
39571
- "scope",
39572
- "defaultStrategy"
39573
- ]);
39574
- const NUMBER_KEYS = /* @__PURE__ */ new Set([
39575
- "minStars",
39576
- "maxIssueAgeDays",
39577
- "minRepoScoreThreshold",
39578
- "interPhaseDelayMs",
39579
- "broadPhaseDelayMs",
39580
- "skipBroadWhenSufficientResults",
39581
- "featuresAnchorThreshold",
39582
- "featuresSplitRatio"
39583
- ]);
39584
- const BOOLEAN_KEYS = /* @__PURE__ */ new Set(["includeDocIssues"]);
39585
40302
  server.tool(
39586
40303
  "config-set",
39587
40304
  "Update an oss-scout configuration preference",
39588
40305
  {
39589
40306
  key: external_exports3.string().describe(
39590
- "Preference key to update (e.g. languages, minStars, excludeRepos)"
40307
+ `Preference key to update. One of: ${SORTED_PREFERENCE_KEYS.join(", ")}`
39591
40308
  ),
39592
40309
  value: external_exports3.string().describe(
39593
- "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.'
39594
40311
  )
39595
40312
  },
39596
40313
  async ({ key, value }) => {
39597
- if (!VALID_KEYS.includes(key)) {
39598
- return {
39599
- content: [
39600
- {
39601
- type: "text",
39602
- text: `Unknown config key: "${key}". Valid keys: ${VALID_KEYS.join(", ")}`
39603
- }
39604
- ],
39605
- isError: true
39606
- };
39607
- }
39608
- let parsed;
39609
- if (ARRAY_KEYS.has(key)) {
39610
- try {
39611
- parsed = JSON.parse(value);
39612
- if (!Array.isArray(parsed)) {
39613
- parsed = value.split(",").map((s) => s.trim()).filter(Boolean);
39614
- }
39615
- } catch {
39616
- parsed = value.split(",").map((s) => s.trim()).filter(Boolean);
39617
- }
39618
- } else if (NUMBER_KEYS.has(key)) {
39619
- const num = Number(value);
39620
- if (isNaN(num)) {
39621
- return {
39622
- content: [
39623
- { type: "text", text: `Invalid number for "${key}": "${value}"` }
39624
- ],
39625
- isError: true
39626
- };
39627
- }
39628
- parsed = num;
39629
- } else if (BOOLEAN_KEYS.has(key)) {
39630
- const lower = value.toLowerCase();
39631
- if (lower === "true" || lower === "yes") parsed = true;
39632
- else if (lower === "false" || lower === "no") parsed = false;
39633
- else {
39634
- return {
39635
- content: [
39636
- {
39637
- type: "text",
39638
- text: `Invalid boolean for "${key}": "${value}". Use true/false or yes/no.`
39639
- }
39640
- ],
39641
- isError: true
39642
- };
39643
- }
39644
- } else {
39645
- parsed = value;
39646
- }
39647
- const currentPrefs = scout.getPreferences();
39648
- const candidate = { ...currentPrefs, [key]: parsed };
39649
- const result = ScoutPreferencesSchema.safeParse(candidate);
39650
- if (!result.success) {
39651
- 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);
39652
40319
  return {
39653
- content: [{ type: "text", text: `Validation error: ${issues}` }],
40320
+ content: [{ type: "text", text: msg }],
39654
40321
  isError: true
39655
40322
  };
39656
40323
  }
39657
- scout.updatePreferences({ [key]: parsed });
40324
+ scout.updatePreferences({
40325
+ [key]: validated[key]
40326
+ });
39658
40327
  await scout.checkpoint();
39659
40328
  const updated = scout.getPreferences();
39660
40329
  return {
@@ -39714,25 +40383,34 @@ function registerResources(server, scout) {
39714
40383
  }
39715
40384
 
39716
40385
  // src/index.ts
39717
- async function main() {
39718
- const token = getGitHubToken();
39719
- if (!token) {
39720
- process.stderr.write(
39721
- "oss-scout-mcp: GitHub token required.\nSet GITHUB_TOKEN or run `gh auth login`.\n"
39722
- );
39723
- process.exit(1);
39724
- }
39725
- 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) {
39726
40392
  const server = new McpServer(
39727
- { name: "oss-scout-mcp", version: "0.2.1" },
40393
+ { name: SERVER_INFO.name, version: SERVER_INFO.version },
39728
40394
  { capabilities: { resources: {}, tools: {} } }
39729
40395
  );
39730
40396
  registerTools(server, scout);
39731
40397
  registerResources(server, scout);
39732
- const transport = new StdioServerTransport();
39733
- await server.connect(transport);
40398
+ return server;
39734
40399
  }
39735
- 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) => {
39736
40414
  process.stderr.write(
39737
40415
  `oss-scout-mcp fatal: ${err instanceof Error ? err.message : String(err)}
39738
40416
  `