@oss-scout/mcp 0.8.0 → 0.10.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,15 @@ 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(),
35870
+ // Retained for backward compatibility but no longer affects the repo score
35871
+ // (#167): nothing computes it, and hasActiveMaintainers is the live activity
35872
+ // proxy. Kept so old persisted state and the search JSON output still parse.
35807
35873
  isResponsive: external_exports3.boolean(),
35808
35874
  hasHostileComments: external_exports3.boolean()
35809
35875
  });
35810
- var RepoScoreSchema = external_exports3.object({
35876
+ var RepoScoreSchema = external_exports3.looseObject({
35811
35877
  repo: external_exports3.string(),
35812
35878
  score: external_exports3.number(),
35813
35879
  mergedPRCount: external_exports3.number(),
@@ -35819,17 +35885,17 @@ var RepoScoreSchema = external_exports3.object({
35819
35885
  stargazersCount: external_exports3.number().optional(),
35820
35886
  language: external_exports3.string().nullable().optional()
35821
35887
  });
35822
- var StoredMergedPRSchema = external_exports3.object({
35888
+ var StoredMergedPRSchema = external_exports3.looseObject({
35823
35889
  url: external_exports3.string(),
35824
35890
  title: external_exports3.string(),
35825
35891
  mergedAt: external_exports3.string()
35826
35892
  });
35827
- var StoredClosedPRSchema = external_exports3.object({
35893
+ var StoredClosedPRSchema = external_exports3.looseObject({
35828
35894
  url: external_exports3.string(),
35829
35895
  title: external_exports3.string(),
35830
35896
  closedAt: external_exports3.string()
35831
35897
  });
35832
- var StoredOpenPRSchema = external_exports3.object({
35898
+ var StoredOpenPRSchema = external_exports3.looseObject({
35833
35899
  url: external_exports3.string(),
35834
35900
  title: external_exports3.string(),
35835
35901
  openedAt: external_exports3.string()
@@ -35889,7 +35955,7 @@ var TrackedIssueSchema = external_exports3.object({
35889
35955
  vetted: external_exports3.boolean(),
35890
35956
  vettingResult: IssueVettingResultSchema.optional()
35891
35957
  });
35892
- var SkippedIssueSchema = external_exports3.object({
35958
+ var SkippedIssueSchema = external_exports3.looseObject({
35893
35959
  url: external_exports3.string(),
35894
35960
  repo: external_exports3.string(),
35895
35961
  number: external_exports3.number(),
@@ -35897,7 +35963,7 @@ var SkippedIssueSchema = external_exports3.object({
35897
35963
  skippedAt: external_exports3.string()
35898
35964
  });
35899
35965
  var HorizonSchema = external_exports3.enum(["quick-win", "bigger-bet"]);
35900
- var SavedCandidateSchema = external_exports3.object({
35966
+ var SavedCandidateSchema = external_exports3.looseObject({
35901
35967
  issueUrl: external_exports3.string(),
35902
35968
  repo: external_exports3.string(),
35903
35969
  number: external_exports3.number(),
@@ -35905,14 +35971,23 @@ var SavedCandidateSchema = external_exports3.object({
35905
35971
  labels: external_exports3.array(external_exports3.string()),
35906
35972
  recommendation: external_exports3.enum(["approve", "skip", "needs_review"]),
35907
35973
  viabilityScore: external_exports3.number(),
35908
- searchPriority: external_exports3.string(),
35974
+ // Tightened from z.string() to the actual 3-value union (#158). `.catch`
35975
+ // keeps old persisted state loadable: any unrecognized stored value (or a
35976
+ // future-version value) decodes to "normal" instead of failing the parse.
35977
+ searchPriority: external_exports3.enum(["merged_pr", "starred", "normal"]).catch("normal"),
35909
35978
  firstSeenAt: external_exports3.string(),
35910
35979
  lastSeenAt: external_exports3.string(),
35911
35980
  lastScore: external_exports3.number(),
35912
- horizon: HorizonSchema.optional()
35981
+ horizon: HorizonSchema.optional(),
35982
+ /**
35983
+ * Availability status recorded by the previous `vet-list` run, so the next
35984
+ * run can report transitions ("was available, now claimed") instead of
35985
+ * re-diffing full snapshots (#165). "error" is never stored.
35986
+ */
35987
+ lastStatus: external_exports3.enum(["still_available", "claimed", "closed", "has_pr"]).optional()
35913
35988
  });
35914
35989
  var PersistenceModeSchema = external_exports3.enum(["local", "gist"]);
35915
- var ScoutPreferencesSchema = external_exports3.object({
35990
+ var ScoutPreferencesSchema = external_exports3.looseObject({
35916
35991
  githubUsername: external_exports3.string().default(""),
35917
35992
  languages: external_exports3.array(external_exports3.string()).default(["any"]),
35918
35993
  labels: external_exports3.array(external_exports3.string()).default(["good first issue", "help wanted"]),
@@ -35928,8 +36003,28 @@ var ScoutPreferencesSchema = external_exports3.object({
35928
36003
  interPhaseDelayMs: external_exports3.number().min(0).max(12e4).default(3e4),
35929
36004
  persistence: PersistenceModeSchema.default("local"),
35930
36005
  defaultStrategy: external_exports3.array(SearchStrategySchema).optional(),
36006
+ /**
36007
+ * Persisted personalization defaults (#168). The `--prefer-languages`,
36008
+ * `--prefer-repos`, and `--diversity-ratio` search flags override these when
36009
+ * passed, so a stored preference removes the need to retype the boost every
36010
+ * search. Empty / 0 disables the corresponding signal (same as the flags).
36011
+ */
36012
+ preferLanguages: external_exports3.array(external_exports3.string()).default([]),
36013
+ preferRepos: external_exports3.array(external_exports3.string()).default([]),
36014
+ diversityRatio: external_exports3.number().min(0).max(1).default(0),
36015
+ // Soft penalty (milder than the hard excludeRepos filter): candidates in
36016
+ // these `owner/repo` slugs are pushed down the ranking but not removed (#168).
36017
+ avoidRepos: external_exports3.array(external_exports3.string()).default([]),
36018
+ // Soft boost for candidates whose issue labels match one of these types,
36019
+ // case-insensitive (e.g. "bug", "good first issue") (#168).
36020
+ boostIssueTypes: external_exports3.array(external_exports3.string()).default([]),
35931
36021
  broadPhaseDelayMs: external_exports3.number().min(0).max(3e5).default(9e4),
35932
- skipBroadWhenSufficientResults: external_exports3.number().int().min(0).max(100).default(15),
36022
+ /**
36023
+ * Skip the expensive broad phase once this many candidates were found by
36024
+ * the cheaper phases. Clamped at runtime to maxResults - 1 so it stays
36025
+ * satisfiable; 0 disables skipping entirely.
36026
+ */
36027
+ skipBroadWhenSufficientResults: external_exports3.number().int().min(0).max(100).default(8),
35933
36028
  /**
35934
36029
  * Optional Ollama model id used for SLM pre-triage during vetting
35935
36030
  * (oss-autopilot#1122). Empty disables the feature. Recommended values:
@@ -35956,7 +36051,7 @@ var ScoutPreferencesSchema = external_exports3.object({
35956
36051
  */
35957
36052
  featuresSplitRatio: external_exports3.number().min(0).max(1).default(0.6)
35958
36053
  });
35959
- var ScoutStateSchema = external_exports3.object({
36054
+ var ScoutStateSchema = external_exports3.looseObject({
35960
36055
  version: external_exports3.literal(1),
35961
36056
  preferences: ScoutPreferencesSchema.default(() => ScoutPreferencesSchema.parse({})),
35962
36057
  repoScores: external_exports3.record(external_exports3.string(), RepoScoreSchema).default({}),
@@ -35967,10 +36062,30 @@ var ScoutStateSchema = external_exports3.object({
35967
36062
  openPRs: external_exports3.array(StoredOpenPRSchema).default([]),
35968
36063
  savedResults: external_exports3.array(SavedCandidateSchema).default([]),
35969
36064
  skippedIssues: external_exports3.array(SkippedIssueSchema).default([]),
36065
+ /**
36066
+ * Deletion tombstones (#117). Without these, the gist union-merge would
36067
+ * resurrect any saved result or skipped issue removed on one machine the
36068
+ * next time it merged against another machine's copy that still had it.
36069
+ * A tombstone suppresses an item across a merge until the item is
36070
+ * re-added with a newer timestamp. Pruned after 90 days.
36071
+ */
36072
+ tombstones: external_exports3.array(external_exports3.looseObject({
36073
+ url: external_exports3.string(),
36074
+ removedAt: external_exports3.string()
36075
+ })).default([]),
36076
+ /**
36077
+ * When preferences were last changed (#117). The gist merge previously
36078
+ * always took the remote preferences, silently reverting a local edit;
36079
+ * the side with the fresher timestamp now wins.
36080
+ */
36081
+ preferencesUpdatedAt: external_exports3.string().optional(),
35970
36082
  lastSearchAt: external_exports3.string().optional(),
35971
36083
  lastRunAt: external_exports3.string().default(() => (/* @__PURE__ */ new Date()).toISOString()),
35972
36084
  gistId: external_exports3.string().optional()
35973
36085
  });
36086
+ function parseScoutState(raw) {
36087
+ return ScoutStateSchema.parse(raw);
36088
+ }
35974
36089
 
35975
36090
  // ../core/dist/core/issue-filtering.js
35976
36091
  var DOC_ONLY_LABELS = /* @__PURE__ */ new Set([
@@ -36268,6 +36383,10 @@ var fs2 = __toESM(require("fs"), 1);
36268
36383
  var path2 = __toESM(require("path"), 1);
36269
36384
  var crypto = __toESM(require("crypto"), 1);
36270
36385
  var MODULE4 = "http-cache";
36386
+ var CACHE_SCHEMA_VERSION = "v1";
36387
+ function versionedCacheKey(key) {
36388
+ return `${CACHE_SCHEMA_VERSION}:${key}`;
36389
+ }
36271
36390
  var DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
36272
36391
  var HttpCache = class {
36273
36392
  cacheDir;
@@ -36290,13 +36409,21 @@ var HttpCache = class {
36290
36409
  * (e.g., caching aggregated results from paginated API calls).
36291
36410
  */
36292
36411
  getIfFresh(key, maxAgeMs) {
36412
+ return this.getEntryIfFresh(key, maxAgeMs)?.body ?? null;
36413
+ }
36414
+ /**
36415
+ * Like {@link getIfFresh}, but returns the whole entry so callers can
36416
+ * distinguish "no fresh entry" (null) from a legitimately cached falsy
36417
+ * body (`0`, `""`, `false`, `null`).
36418
+ */
36419
+ getEntryIfFresh(key, maxAgeMs) {
36293
36420
  const entry = this.get(key);
36294
36421
  if (!entry)
36295
36422
  return null;
36296
36423
  const age = Date.now() - new Date(entry.cachedAt).getTime();
36297
36424
  if (!Number.isFinite(age) || age < 0 || age > maxAgeMs)
36298
36425
  return null;
36299
- return entry.body;
36426
+ return entry;
36300
36427
  }
36301
36428
  /**
36302
36429
  * Look up a cached response. Returns `null` if no cache entry exists.
@@ -36447,12 +36574,21 @@ function getHttpCache() {
36447
36574
  }
36448
36575
  return _httpCache;
36449
36576
  }
36450
- async function cachedRequest(cache, url2, fetcher) {
36451
- const existing = cache.getInflight(url2);
36577
+ async function withInflightDedup(cache, key, fn) {
36578
+ const existing = cache.getInflight(key);
36452
36579
  if (existing) {
36453
- debug(MODULE4, `Dedup hit for ${url2}`);
36580
+ debug(MODULE4, `Dedup hit for ${key}`);
36454
36581
  return await existing;
36455
36582
  }
36583
+ const promise2 = fn();
36584
+ const cleanup = cache.setInflight(key, promise2);
36585
+ try {
36586
+ return await promise2;
36587
+ } finally {
36588
+ cleanup();
36589
+ }
36590
+ }
36591
+ async function cachedRequest(cache, url2, fetcher) {
36456
36592
  const doFetch = async () => {
36457
36593
  const extraHeaders = {};
36458
36594
  const cached2 = cache.get(url2);
@@ -36473,28 +36609,34 @@ async function cachedRequest(cache, url2, fetcher) {
36473
36609
  debug(MODULE4, `304 cache hit for ${url2}`);
36474
36610
  return freshCached.body;
36475
36611
  }
36612
+ debug(MODULE4, `304 but cache entry vanished for ${url2}, refetching`);
36613
+ const response = await fetcher({});
36614
+ const etag = response.headers?.["etag"];
36615
+ if (etag) {
36616
+ cache.set(url2, etag, response.data);
36617
+ }
36618
+ return response.data;
36476
36619
  }
36477
36620
  throw err;
36478
36621
  }
36479
36622
  };
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
- }
36623
+ return withInflightDedup(cache, url2, doFetch);
36488
36624
  }
36489
36625
  async function cachedTimeBased(cache, key, maxAgeMs, fetcher) {
36490
- const cached2 = cache.getIfFresh(key, maxAgeMs);
36626
+ const cached2 = cache.getEntryIfFresh(key, maxAgeMs);
36491
36627
  if (cached2) {
36492
36628
  debug(MODULE4, `Time-based cache hit for ${key}`);
36493
- return cached2;
36629
+ return cached2.body;
36494
36630
  }
36495
- const result = await fetcher();
36496
- cache.set(key, "", result);
36497
- return result;
36631
+ return withInflightDedup(cache, key, async () => {
36632
+ const fresh = cache.getEntryIfFresh(key, maxAgeMs);
36633
+ if (fresh) {
36634
+ return fresh.body;
36635
+ }
36636
+ const result = await fetcher();
36637
+ cache.set(key, "", result);
36638
+ return result;
36639
+ });
36498
36640
  }
36499
36641
  function isNotModifiedError(err) {
36500
36642
  return getHttpStatusCode(err) === 304;
@@ -36531,23 +36673,36 @@ function buildLinkedPRFromTimelineEvent(e, context) {
36531
36673
  };
36532
36674
  }
36533
36675
  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"
36676
+ var ISSUE_OBJECT = String.raw`(?:this\b|it\b|that\b|the\b|#\d+|issue\b)`;
36677
+ var FIRST_PERSON_CLAIM_PATTERNS = [
36678
+ new RegExp(String.raw`\bi\s*(?:'ll|will) take ${ISSUE_OBJECT}`),
36679
+ new RegExp(String.raw`\bi\s*(?:'d|would) (?:like|love) to work on ${ISSUE_OBJECT}`),
36680
+ /\bi\s*(?:'m|am) on it\b/,
36681
+ /\bi\s*(?:'ll|will) submit a pr\b/,
36682
+ /\bassigned to me\b/
36683
+ ];
36684
+ var GENERIC_WORKING_PATTERNS = [
36685
+ /\bworking on (?:this|it)\b/,
36686
+ /\bworking on a (?:fix|pr)\b/
36550
36687
  ];
36688
+ var PERMISSION_CLAIM_PATTERN = new RegExp(String.raw`\b(?:can|may|could) i (?:work on|take) ${ISSUE_OBJECT}`);
36689
+ var NON_CLAIM_SUBJECTS = /\b(?:anyone|anybody|someone|somebody|who|whoever|nobody|no[- ]?one|not)\b/;
36690
+ function commentClaimsIssue(body) {
36691
+ const clauses = body.toLowerCase().split(/(?<=[.!?])|\n+/);
36692
+ for (const clause of clauses) {
36693
+ if (PERMISSION_CLAIM_PATTERN.test(clause))
36694
+ return true;
36695
+ if (clause.trimEnd().endsWith("?"))
36696
+ continue;
36697
+ if (FIRST_PERSON_CLAIM_PATTERNS.some((p) => p.test(clause)))
36698
+ return true;
36699
+ if (NON_CLAIM_SUBJECTS.test(clause))
36700
+ continue;
36701
+ if (GENERIC_WORKING_PATTERNS.some((p) => p.test(clause)))
36702
+ return true;
36703
+ }
36704
+ return false;
36705
+ }
36551
36706
  async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
36552
36707
  try {
36553
36708
  const timeline = await paginateAll((page) => octokit.issues.listEventsForTimeline({
@@ -36572,67 +36727,68 @@ async function checkNoExistingPR(octokit, owner, repo, issueNumber) {
36572
36727
  }
36573
36728
  return { passed: linkedPRCount === 0, linkedPR };
36574
36729
  } catch (error48) {
36575
- if (getHttpStatusCode(error48) === 401 || isRateLimitError(error48)) {
36576
- throw error48;
36577
- }
36730
+ rethrowIfFatal(error48);
36578
36731
  const errMsg = errorMessage(error48);
36579
36732
  warn(MODULE5, `Failed to check for existing PRs on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming no existing PR.`);
36580
36733
  return { passed: true, inconclusive: true, reason: errMsg, linkedPR: null };
36581
36734
  }
36582
36735
  }
36583
36736
  var MERGED_PR_CACHE_TTL_MS = 15 * 60 * 1e3;
36584
- async function checkUserMergedPRsInRepo(octokit, owner, repo) {
36737
+ async function checkUserMergedPRsInRepo(octokit, owner, repo, tracker = getSearchBudgetTracker()) {
36585
36738
  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();
36739
+ const cacheKey = versionedCacheKey(`merged-prs:${owner}/${repo}`);
36740
+ return withInflightDedup(cache, cacheKey, async () => {
36741
+ const cached2 = cache.getIfFresh(cacheKey, MERGED_PR_CACHE_TTL_MS);
36742
+ if (cached2 != null && typeof cached2 === "number") {
36743
+ return cached2;
36604
36744
  }
36605
- } catch (error48) {
36606
- if (getHttpStatusCode(error48) === 401 || isRateLimitError(error48)) {
36607
- throw error48;
36745
+ try {
36746
+ await tracker.waitForBudget();
36747
+ try {
36748
+ const { data } = await octokit.search.issuesAndPullRequests({
36749
+ q: `repo:${owner}/${repo} is:pr is:merged author:@me`,
36750
+ per_page: 1
36751
+ // We only need total_count
36752
+ });
36753
+ cache.set(cacheKey, "", data.total_count);
36754
+ return data.total_count;
36755
+ } finally {
36756
+ tracker.recordCall();
36757
+ }
36758
+ } catch (error48) {
36759
+ rethrowIfFatal(error48);
36760
+ const errMsg = errorMessage(error48);
36761
+ warn(MODULE5, `Could not check merged PRs in ${owner}/${repo}: ${errMsg}. Treating as unknown.`);
36762
+ return null;
36608
36763
  }
36609
- const errMsg = errorMessage(error48);
36610
- warn(MODULE5, `Could not check merged PRs in ${owner}/${repo}: ${errMsg}. Defaulting to 0.`);
36611
- return 0;
36612
- }
36764
+ });
36613
36765
  }
36614
36766
  async function checkNotClaimed(octokit, owner, repo, issueNumber, commentCount) {
36615
36767
  if (commentCount === 0)
36616
36768
  return { passed: true };
36617
36769
  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);
36770
+ const PER_PAGE = 100;
36771
+ const lastPage = Math.max(1, Math.ceil(commentCount / PER_PAGE));
36772
+ const pagesToFetch = lastPage > 1 ? [lastPage - 1, lastPage] : [1];
36773
+ const recentComments = [];
36774
+ for (const page of pagesToFetch) {
36775
+ const response = await octokit.issues.listComments({
36776
+ owner,
36777
+ repo,
36778
+ issue_number: issueNumber,
36779
+ per_page: PER_PAGE,
36780
+ page
36781
+ });
36782
+ recentComments.push(...response.data);
36783
+ }
36625
36784
  for (const comment of recentComments) {
36626
- const body = (comment.body || "").toLowerCase();
36627
- if (CLAIM_PHRASES.some((phrase) => body.includes(phrase))) {
36785
+ if (commentClaimsIssue(comment.body || "")) {
36628
36786
  return { passed: false };
36629
36787
  }
36630
36788
  }
36631
36789
  return { passed: true };
36632
36790
  } catch (error48) {
36633
- if (getHttpStatusCode(error48) === 401 || isRateLimitError(error48)) {
36634
- throw error48;
36635
- }
36791
+ rethrowIfFatal(error48);
36636
36792
  const errMsg = errorMessage(error48);
36637
36793
  warn(MODULE5, `Failed to check claim status on ${owner}/${repo}#${issueNumber}: ${errMsg}. Assuming not claimed.`);
36638
36794
  return { passed: true, inconclusive: true, reason: errMsg };
@@ -36653,8 +36809,30 @@ function analyzeRequirements(body) {
36653
36809
  return indicators.filter(Boolean).length >= 2;
36654
36810
  }
36655
36811
 
36812
+ // ../core/dist/core/probe-repo-file.js
36813
+ var MODULE6 = "probe-repo-file";
36814
+ async function probeRepoFile(octokit, owner, repo, path5) {
36815
+ try {
36816
+ const { data } = await octokit.repos.getContent({ owner, repo, path: path5 });
36817
+ if (data && typeof data === "object" && "content" in data && typeof data.content === "string") {
36818
+ return {
36819
+ text: Buffer.from(data.content, "base64").toString("utf-8"),
36820
+ transient: false
36821
+ };
36822
+ }
36823
+ return { text: null, transient: false };
36824
+ } catch (error48) {
36825
+ const status = getHttpStatusCode(error48);
36826
+ if (status === 404)
36827
+ return { text: null, transient: false };
36828
+ rethrowIfFatal(error48);
36829
+ warn(MODULE6, `Unexpected error fetching ${path5} from ${owner}/${repo}: ${errorMessage(error48)}`);
36830
+ return { text: null, transient: true };
36831
+ }
36832
+ }
36833
+
36656
36834
  // ../core/dist/core/repo-health.js
36657
- var MODULE6 = "repo-health";
36835
+ var MODULE7 = "repo-health";
36658
36836
  var guidelinesCache = /* @__PURE__ */ new Map();
36659
36837
  var CACHE_TTL_MS = 60 * 60 * 1e3;
36660
36838
  var HEALTH_CACHE_TTL_MS = 4 * 60 * 60 * 1e3;
@@ -36705,19 +36883,11 @@ async function checkProjectHealth(octokit, owner, repo) {
36705
36883
  };
36706
36884
  });
36707
36885
  } catch (error48) {
36708
- if (getHttpStatusCode(error48) === 401 || isRateLimitError(error48)) {
36709
- throw error48;
36710
- }
36886
+ rethrowIfFatal(error48);
36711
36887
  const errMsg = errorMessage(error48);
36712
- warn(MODULE6, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
36888
+ warn(MODULE7, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
36713
36889
  return {
36714
36890
  repo: `${owner}/${repo}`,
36715
- lastCommitAt: "",
36716
- daysSinceLastCommit: 999,
36717
- openIssuesCount: 0,
36718
- avgIssueResponseDays: 0,
36719
- ciStatus: "unknown",
36720
- isActive: false,
36721
36891
  checkFailed: true,
36722
36892
  failureReason: errMsg
36723
36893
  };
@@ -36729,18 +36899,27 @@ async function fetchContributionGuidelines(octokit, owner, repo) {
36729
36899
  if (cached2 && Date.now() - cached2.fetchedAt < CACHE_TTL_MS) {
36730
36900
  return cached2.guidelines;
36731
36901
  }
36902
+ const inflight = guidelinesInflight.get(cacheKey);
36903
+ if (inflight)
36904
+ return inflight;
36905
+ const promise2 = fetchContributionGuidelinesUncached(octokit, owner, repo);
36906
+ guidelinesInflight.set(cacheKey, promise2);
36907
+ try {
36908
+ return await promise2;
36909
+ } finally {
36910
+ guidelinesInflight.delete(cacheKey);
36911
+ }
36912
+ }
36913
+ var guidelinesInflight = /* @__PURE__ */ new Map();
36914
+ async function fetchContributionGuidelinesUncached(octokit, owner, repo) {
36915
+ const cacheKey = `${owner}/${repo}`;
36732
36916
  const filesToCheck = [
36733
36917
  "CONTRIBUTING.md",
36734
36918
  ".github/CONTRIBUTING.md",
36735
36919
  "docs/CONTRIBUTING.md",
36736
36920
  "contributing.md"
36737
36921
  ];
36738
- const results = await Promise.allSettled(filesToCheck.map((file2) => octokit.repos.getContent({ owner, repo, path: file2 }).then(({ data }) => {
36739
- if ("content" in data) {
36740
- return Buffer.from(data.content, "base64").toString("utf-8");
36741
- }
36742
- return null;
36743
- })));
36922
+ const results = await Promise.allSettled(filesToCheck.map((file2) => probeRepoFile(octokit, owner, repo, file2)));
36744
36923
  for (const result of results) {
36745
36924
  if (result.status !== "rejected")
36746
36925
  continue;
@@ -36748,20 +36927,13 @@ async function fetchContributionGuidelines(octokit, owner, repo) {
36748
36927
  throw result.reason;
36749
36928
  }
36750
36929
  }
36751
- for (let i = 0; i < results.length; i++) {
36752
- const result = results[i];
36753
- if (result.status === "fulfilled" && result.value) {
36754
- const guidelines = parseContributionGuidelines(result.value);
36930
+ for (const result of results) {
36931
+ if (result.status === "fulfilled" && result.value.text) {
36932
+ const guidelines = parseContributionGuidelines(result.value.text);
36755
36933
  guidelinesCache.set(cacheKey, { guidelines, fetchedAt: Date.now() });
36756
36934
  pruneCache();
36757
36935
  return guidelines;
36758
36936
  }
36759
- if (result.status === "rejected") {
36760
- const status = getHttpStatusCode(result.reason);
36761
- if (status !== 404) {
36762
- warn(MODULE6, `Unexpected error fetching ${filesToCheck[i]} from ${owner}/${repo}: ${errorMessage(result.reason)}`);
36763
- }
36764
- }
36765
36937
  }
36766
36938
  guidelinesCache.set(cacheKey, {
36767
36939
  guidelines: void 0,
@@ -36776,7 +36948,7 @@ function parseContributionGuidelines(content) {
36776
36948
  };
36777
36949
  const lowerContent = content.toLowerCase();
36778
36950
  if (lowerContent.includes("branch")) {
36779
- const branchMatch = content.match(/branch[^\n]*(?:named?|format|convention)[^\n]*[`"]([^`"]+)[`"]/i);
36951
+ const branchMatch = content.match(/branch[^\n]{0,200}?(?:named?|format|convention)[^\n]{0,200}?[`"]([^`"\n]{1,100})[`"]/i);
36780
36952
  if (branchMatch) {
36781
36953
  guidelines.branchNamingConvention = branchMatch[1];
36782
36954
  }
@@ -36784,7 +36956,7 @@ function parseContributionGuidelines(content) {
36784
36956
  if (lowerContent.includes("conventional commit")) {
36785
36957
  guidelines.commitMessageFormat = "conventional commits";
36786
36958
  } else if (lowerContent.includes("commit message")) {
36787
- const commitMatch = content.match(/commit message[^\n]*[`"]([^`"]+)[`"]/i);
36959
+ const commitMatch = content.match(/commit message[^\n]{0,200}?[`"]([^`"\n]{1,100})[`"]/i);
36788
36960
  if (commitMatch) {
36789
36961
  guidelines.commitMessageFormat = commitMatch[1];
36790
36962
  }
@@ -36803,14 +36975,13 @@ function parseContributionGuidelines(content) {
36803
36975
  guidelines.linter = "RuboCop";
36804
36976
  else if (lowerContent.includes("prettier"))
36805
36977
  guidelines.formatter = "Prettier";
36806
- if (lowerContent.includes("cla") || lowerContent.includes("contributor license agreement")) {
36978
+ if (/\bcla\b/.test(lowerContent) || lowerContent.includes("contributor license agreement")) {
36807
36979
  guidelines.claRequired = true;
36808
36980
  }
36809
36981
  return guidelines;
36810
36982
  }
36811
36983
 
36812
36984
  // ../core/dist/core/anti-llm-policy.js
36813
- var MODULE7 = "anti-llm-policy";
36814
36985
  var POLICY_SCAN_CACHE_TTL_MS = 60 * 60 * 1e3;
36815
36986
  var ANTI_LLM_KEYWORDS = [
36816
36987
  "no ai-generated",
@@ -36873,28 +37044,8 @@ var SOURCE_FILE_FAMILIES = [
36873
37044
  paths: ["README.md", "readme.md", "Readme.md"]
36874
37045
  }
36875
37046
  ];
36876
- async function fetchFileText(octokit, owner, repo, path5) {
36877
- try {
36878
- const { data } = await octokit.repos.getContent({ owner, repo, path: path5 });
36879
- if ("content" in data && typeof data.content === "string") {
36880
- return {
36881
- text: Buffer.from(data.content, "base64").toString("utf-8"),
36882
- transient: false
36883
- };
36884
- }
36885
- return { text: null, transient: false };
36886
- } catch (error48) {
36887
- const status = getHttpStatusCode(error48);
36888
- if (status === 404)
36889
- return { text: null, transient: false };
36890
- if (status === 401 || isRateLimitError(error48))
36891
- throw error48;
36892
- warn(MODULE7, `Unexpected error fetching ${path5} from ${owner}/${repo}: ${errorMessage(error48)}`);
36893
- return { text: null, transient: true };
36894
- }
36895
- }
36896
37047
  async function fetchFamilyText(octokit, owner, repo, paths) {
36897
- const results = await Promise.allSettled(paths.map((p) => fetchFileText(octokit, owner, repo, p)));
37048
+ const results = await Promise.allSettled(paths.map((p) => probeRepoFile(octokit, owner, repo, p)));
36898
37049
  let hadTransientFailure = false;
36899
37050
  for (const result of results) {
36900
37051
  if (result.status === "fulfilled") {
@@ -36925,7 +37076,7 @@ function isAntiLLMPolicyResult(value) {
36925
37076
  }
36926
37077
  async function fetchAndScanAntiLLMPolicy(octokit, owner, repo, options) {
36927
37078
  const cache = getHttpCache();
36928
- const cacheKey = `anti-llm-policy:${owner}/${repo}`;
37079
+ const cacheKey = versionedCacheKey(`anti-llm-policy:${owner}/${repo}`);
36929
37080
  const cached2 = cache.getIfFresh(cacheKey, POLICY_SCAN_CACHE_TTL_MS);
36930
37081
  if (isAntiLLMPolicyResult(cached2))
36931
37082
  return cached2;
@@ -36964,6 +37115,76 @@ async function fetchAndScanAntiLLMPolicy(octokit, owner, repo, options) {
36964
37115
  return noMatch;
36965
37116
  }
36966
37117
 
37118
+ // ../core/dist/core/issue-graphql.js
37119
+ var MODULE8 = "issue-graphql";
37120
+ function issueCoreKey(owner, repo, number4) {
37121
+ return `${owner}/${repo}#${number4}`;
37122
+ }
37123
+ function normalizeNode(node) {
37124
+ return {
37125
+ id: node.databaseId,
37126
+ title: node.title,
37127
+ body: node.body ?? "",
37128
+ state: node.state === "CLOSED" ? "closed" : "open",
37129
+ labels: node.labels.nodes.map((l) => l.name),
37130
+ commentCount: node.comments.totalCount,
37131
+ createdAt: node.createdAt,
37132
+ updatedAt: node.updatedAt
37133
+ };
37134
+ }
37135
+ async function prefetchIssueCores(octokit, issues) {
37136
+ const result = /* @__PURE__ */ new Map();
37137
+ if (issues.length === 0)
37138
+ return result;
37139
+ const unique = [
37140
+ ...new Map(issues.map((i) => [issueCoreKey(i.owner, i.repo, i.number), i])).values()
37141
+ ];
37142
+ const varDefs = [];
37143
+ const selections = [];
37144
+ const variables = {};
37145
+ unique.forEach((iss, i) => {
37146
+ varDefs.push(`$o${i}: String!, $n${i}: String!, $num${i}: Int!`);
37147
+ variables[`o${i}`] = iss.owner;
37148
+ variables[`n${i}`] = iss.repo;
37149
+ variables[`num${i}`] = iss.number;
37150
+ selections.push(`i${i}: repository(owner: $o${i}, name: $n${i}) {
37151
+ issue(number: $num${i}) {
37152
+ databaseId
37153
+ title
37154
+ body
37155
+ state
37156
+ labels(first: 100) { nodes { name } }
37157
+ comments { totalCount }
37158
+ createdAt
37159
+ updatedAt
37160
+ }
37161
+ }`);
37162
+ });
37163
+ const query = `query batchIssueCores(${varDefs.join(", ")}) {
37164
+ ${selections.join("\n")}
37165
+ }`;
37166
+ let data;
37167
+ try {
37168
+ data = await octokit.graphql(query, variables);
37169
+ } catch (err) {
37170
+ rethrowIfFatal(err);
37171
+ const partial2 = err.data;
37172
+ if (partial2) {
37173
+ data = partial2;
37174
+ } else {
37175
+ warn(MODULE8, `GraphQL prefetch failed, falling back to REST: ${errorMessage(err)}`);
37176
+ return result;
37177
+ }
37178
+ }
37179
+ unique.forEach((iss, i) => {
37180
+ const node = data?.[`i${i}`]?.issue;
37181
+ if (!node || node.databaseId == null)
37182
+ return;
37183
+ result.set(issueCoreKey(iss.owner, iss.repo, iss.number), normalizeNode(node));
37184
+ });
37185
+ return result;
37186
+ }
37187
+
36967
37188
  // ../core/dist/core/slm-triage.js
36968
37189
  var DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434";
36969
37190
  var DEFAULT_TIMEOUT_MS = 15e3;
@@ -37076,15 +37297,96 @@ function isValidTriageShape(value) {
37076
37297
  }
37077
37298
 
37078
37299
  // ../core/dist/core/issue-vetting.js
37079
- var MODULE8 = "issue-vetting";
37300
+ var MODULE9 = "issue-vetting";
37080
37301
  var MAX_CONCURRENT_VETTING = 3;
37081
37302
  var VETTING_CACHE_TTL_MS = 15 * 60 * 1e3;
37303
+ function deriveRecommendation(input) {
37304
+ const notes = [];
37305
+ const reasonsToApprove = [];
37306
+ const reasonsToSkip = [];
37307
+ if (!input.noExistingPR)
37308
+ notes.push(input.ownPR ? "Your PR is already in flight for this issue" : "Existing PR found for this issue");
37309
+ if (!input.notClaimed)
37310
+ notes.push("Issue appears to be claimed by someone");
37311
+ if (input.existingPRInconclusive) {
37312
+ notes.push(`Could not verify absence of existing PRs: ${input.existingPRReason || "API error"}`);
37313
+ }
37314
+ if (input.claimInconclusive) {
37315
+ notes.push(`Could not verify claim status: ${input.claimReason || "API error"}`);
37316
+ }
37317
+ if (input.projectCheckFailed) {
37318
+ notes.push(`Could not verify project activity: ${input.projectFailureReason || "API error"}`);
37319
+ } else if (!input.projectIsActive) {
37320
+ notes.push("Project may be inactive");
37321
+ }
37322
+ if (!input.clearRequirements)
37323
+ notes.push("Issue requirements are unclear");
37324
+ if (!input.contributionGuidelinesFound)
37325
+ notes.push("No CONTRIBUTING.md found");
37326
+ if (!input.noExistingPR)
37327
+ reasonsToSkip.push(input.ownPR ? "You already have a PR in flight" : "Has existing PR");
37328
+ if (!input.notClaimed)
37329
+ reasonsToSkip.push("Already claimed");
37330
+ if (!input.projectIsActive && !input.projectCheckFailed)
37331
+ reasonsToSkip.push("Inactive project");
37332
+ if (!input.clearRequirements)
37333
+ reasonsToSkip.push("Unclear requirements");
37334
+ if (input.noExistingPR)
37335
+ reasonsToApprove.push("No existing PR");
37336
+ if (input.notClaimed)
37337
+ reasonsToApprove.push("Not claimed");
37338
+ if (input.projectIsActive && !input.projectCheckFailed)
37339
+ reasonsToApprove.push("Active project");
37340
+ if (input.clearRequirements)
37341
+ reasonsToApprove.push("Clear requirements");
37342
+ if (input.contributionGuidelinesFound)
37343
+ reasonsToApprove.push("Has contribution guidelines");
37344
+ if (input.effectiveMergedCount > 0) {
37345
+ reasonsToApprove.push(`Trusted project (${input.effectiveMergedCount} PR${input.effectiveMergedCount > 1 ? "s" : ""} merged)`);
37346
+ }
37347
+ if (input.orgHasMergedPRs) {
37348
+ reasonsToApprove.push(`Org affinity (merged PRs in other ${input.orgName} repos)`);
37349
+ }
37350
+ if (input.matchesCategory) {
37351
+ reasonsToApprove.push("Matches preferred project category");
37352
+ }
37353
+ if (input.issueClosed) {
37354
+ reasonsToSkip.push("Issue is closed");
37355
+ }
37356
+ let recommendation;
37357
+ if (input.issueClosed) {
37358
+ recommendation = "skip";
37359
+ } else if (input.ownPR) {
37360
+ recommendation = "skip";
37361
+ } else if (input.passedAllChecks) {
37362
+ recommendation = "approve";
37363
+ } else if (reasonsToSkip.length > 2) {
37364
+ recommendation = "skip";
37365
+ } else {
37366
+ recommendation = "needs_review";
37367
+ }
37368
+ const hasInconclusiveChecks = input.projectCheckFailed || input.existingPRInconclusive || input.claimInconclusive || input.mergedCountInconclusive;
37369
+ if (recommendation === "approve" && hasInconclusiveChecks) {
37370
+ recommendation = "needs_review";
37371
+ notes.push("Recommendation downgraded: one or more checks were inconclusive");
37372
+ }
37373
+ return { notes, reasonsToApprove, reasonsToSkip, recommendation };
37374
+ }
37082
37375
  var IssueVetter = class {
37083
37376
  octokit;
37084
37377
  stateReader;
37085
- constructor(octokit, stateReader) {
37378
+ budgetTracker;
37379
+ /**
37380
+ * @param octokit - Authenticated Octokit instance
37381
+ * @param stateReader - Read-only scout state interface
37382
+ * @param budgetTracker - Search budget tracker. Defaults to the shared
37383
+ * singleton so existing callers behave identically; inject a per-search
37384
+ * instance to isolate budget accounting in a long-lived concurrent host.
37385
+ */
37386
+ constructor(octokit, stateReader, budgetTracker = getSearchBudgetTracker()) {
37086
37387
  this.octokit = octokit;
37087
37388
  this.stateReader = stateReader;
37389
+ this.budgetTracker = budgetTracker;
37088
37390
  }
37089
37391
  /**
37090
37392
  * Vet a specific issue — runs all checks and computes recommendation + viability score.
@@ -37098,10 +37400,10 @@ var IssueVetter = class {
37098
37400
  async vetIssue(issueUrl, opts) {
37099
37401
  const cache = getHttpCache();
37100
37402
  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}`;
37403
+ const cacheKey = versionedCacheKey(`vet:${issueUrl}${sigKey}`);
37102
37404
  const cached2 = cache.getIfFresh(cacheKey, VETTING_CACHE_TTL_MS);
37103
37405
  if (cached2 && typeof cached2 === "object" && "issue" in cached2 && "viabilityScore" in cached2) {
37104
- debug(MODULE8, `Vetting cache hit for ${issueUrl}`);
37406
+ debug(MODULE9, `Vetting cache hit for ${issueUrl}`);
37105
37407
  return cached2;
37106
37408
  }
37107
37409
  const parsed = parseGitHubUrl(issueUrl);
@@ -37110,24 +37412,23 @@ var IssueVetter = class {
37110
37412
  }
37111
37413
  const { owner, repo, number: number4 } = parsed;
37112
37414
  const repoFullName = `${owner}/${repo}`;
37113
- const { data: ghIssue } = await this.octokit.issues.get({
37114
- owner,
37115
- repo,
37116
- issue_number: number4
37117
- });
37415
+ const core = opts?.prefetched ?? await this.fetchIssueCore(owner, repo, number4);
37118
37416
  const reposWithMergedPRs = this.stateReader.getReposWithMergedPRs();
37119
37417
  const hasMergedPRsInRepo = reposWithMergedPRs.includes(repoFullName);
37120
37418
  const [existingPRCheck, claimCheck, projectHealth, contributionGuidelines, userMergedPRCount] = await Promise.all([
37121
37419
  checkNoExistingPR(this.octokit, owner, repo, number4),
37122
- checkNotClaimed(this.octokit, owner, repo, number4, ghIssue.comments),
37420
+ checkNotClaimed(this.octokit, owner, repo, number4, core.commentCount),
37123
37421
  checkProjectHealth(this.octokit, owner, repo),
37124
37422
  fetchContributionGuidelines(this.octokit, owner, repo),
37125
- hasMergedPRsInRepo ? Promise.resolve(0) : checkUserMergedPRsInRepo(this.octokit, owner, repo)
37423
+ hasMergedPRsInRepo ? Promise.resolve(0) : checkUserMergedPRsInRepo(this.octokit, owner, repo, this.budgetTracker)
37126
37424
  ]);
37127
37425
  const antiLLMPolicy = await fetchAndScanAntiLLMPolicy(this.octokit, owner, repo, { contributingText: contributionGuidelines?.rawContent });
37128
37426
  const noExistingPR = existingPRCheck.passed;
37129
37427
  const notClaimed = claimCheck.passed;
37130
- const clearRequirements = analyzeRequirements(ghIssue.body || "");
37428
+ const username = this.stateReader.getGitHubUsername?.() ?? "";
37429
+ const linkedPR = existingPRCheck.linkedPR;
37430
+ const ownPR = !noExistingPR && !!linkedPR && linkedPR.state === "open" && !linkedPR.merged && username !== "" && linkedPR.author.toLowerCase() === username.toLowerCase();
37431
+ const clearRequirements = analyzeRequirements(core.body);
37131
37432
  const projectActive = projectHealth.checkFailed ? true : projectHealth.isActive;
37132
37433
  const vettingResult = {
37133
37434
  passedAllChecks: noExistingPR && notClaimed && projectActive && clearRequirements,
@@ -37142,89 +37443,52 @@ var IssueVetter = class {
37142
37443
  linkedPR: existingPRCheck.linkedPR,
37143
37444
  notes: []
37144
37445
  };
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
37446
  const trackedIssue = {
37165
- id: ghIssue.id,
37447
+ id: core.id,
37166
37448
  url: issueUrl,
37167
37449
  repo: repoFullName,
37168
37450
  number: number4,
37169
- title: ghIssue.title,
37451
+ title: core.title,
37170
37452
  status: "candidate",
37171
- labels: ghIssue.labels.map((l) => typeof l === "string" ? l : l.name || ""),
37172
- createdAt: ghIssue.created_at,
37173
- updatedAt: ghIssue.updated_at,
37453
+ labels: core.labels,
37454
+ createdAt: core.createdAt,
37455
+ updatedAt: core.updatedAt,
37174
37456
  vetted: true,
37175
37457
  vettingResult
37176
37458
  };
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
- }
37459
+ const mergedCountInconclusive = !hasMergedPRsInRepo && userMergedPRCount === null;
37460
+ const effectiveMergedCount = hasMergedPRsInRepo ? 1 : userMergedPRCount ?? 0;
37201
37461
  const orgName = repoFullName.split("/")[0];
37202
37462
  let orgHasMergedPRs = false;
37203
37463
  if (orgName && repoFullName.includes("/")) {
37204
37464
  orgHasMergedPRs = reposWithMergedPRs.some((r) => r.startsWith(orgName + "/") && r !== repoFullName);
37205
37465
  }
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);
37466
+ const matchesCategory = repoBelongsToCategory(repoFullName, this.stateReader.getProjectCategories());
37467
+ const issueClosed = core.state === "closed";
37468
+ const hasInconclusiveChecks = projectHealth.checkFailed || !!existingPRCheck.inconclusive || !!claimCheck.inconclusive || mergedCountInconclusive;
37469
+ const { notes, reasonsToApprove, reasonsToSkip, recommendation } = deriveRecommendation({
37470
+ noExistingPR,
37471
+ ownPR,
37472
+ notClaimed,
37473
+ clearRequirements,
37474
+ contributionGuidelinesFound: !!contributionGuidelines,
37475
+ projectIsActive: projectHealth.checkFailed ? false : projectHealth.isActive,
37476
+ projectCheckFailed: !!projectHealth.checkFailed,
37477
+ projectFailureReason: projectHealth.failureReason,
37478
+ existingPRInconclusive: !!existingPRCheck.inconclusive,
37479
+ existingPRReason: existingPRCheck.inconclusive ? existingPRCheck.reason : void 0,
37480
+ claimInconclusive: !!claimCheck.inconclusive,
37481
+ claimReason: claimCheck.inconclusive ? claimCheck.reason : void 0,
37482
+ mergedCountInconclusive,
37483
+ effectiveMergedCount,
37484
+ orgName,
37485
+ orgHasMergedPRs,
37486
+ matchesCategory,
37487
+ issueClosed,
37488
+ passedAllChecks: vettingResult.passedAllChecks
37489
+ });
37490
+ vettingResult.notes = notes;
37491
+ const repoQualityBonus = projectHealth.checkFailed ? 0 : calculateRepoQualityBonus(projectHealth.stargazersCount ?? 0, projectHealth.forksCount ?? 0);
37228
37492
  if (projectHealth.checkFailed && repoQualityBonus === 0) {
37229
37493
  vettingResult.notes.push("Repo quality bonus unavailable: could not fetch star/fork counts due to API error");
37230
37494
  }
@@ -37235,8 +37499,8 @@ var IssueVetter = class {
37235
37499
  isClaimed: !notClaimed,
37236
37500
  clearRequirements,
37237
37501
  hasContributionGuidelines: !!contributionGuidelines,
37238
- issueUpdatedAt: ghIssue.updated_at,
37239
- closedWithoutMergeCount: 0,
37502
+ issueUpdatedAt: core.updatedAt,
37503
+ closedWithoutMergeCount: this.stateReader.getClosedWithoutMergeCount?.(repoFullName) ?? 0,
37240
37504
  mergedPRCount: effectiveMergedCount,
37241
37505
  orgHasMergedPRs,
37242
37506
  repoQualityBonus,
@@ -37250,22 +37514,20 @@ var IssueVetter = class {
37250
37514
  } else if (starredRepos.includes(repoFullName)) {
37251
37515
  searchPriority = "starred";
37252
37516
  }
37253
- const slmConfig = this.stateReader.getSLMTriageConfig?.() ?? {
37254
- model: "",
37255
- host: ""
37256
- };
37517
+ const slmConfig = this.stateReader.getSLMTriageConfig();
37257
37518
  let slmTriage = null;
37258
- if (slmConfig.model) {
37519
+ if (slmConfig) {
37259
37520
  const slmOpts = { model: slmConfig.model };
37260
37521
  if (slmConfig.host)
37261
37522
  slmOpts.host = slmConfig.host;
37262
37523
  slmTriage = await triageWithSLM(buildTriageInput({
37263
- issue: { ...trackedIssue, body: ghIssue.body ?? "" },
37524
+ issue: { ...trackedIssue, body: core.body },
37264
37525
  linkedPR: existingPRCheck.linkedPR ?? null
37265
37526
  }), slmOpts);
37266
37527
  }
37267
37528
  const result = {
37268
37529
  issue: trackedIssue,
37530
+ issueState: issueClosed ? "closed" : "open",
37269
37531
  vettingResult,
37270
37532
  projectHealth,
37271
37533
  antiLLMPolicy,
@@ -37276,9 +37538,33 @@ var IssueVetter = class {
37276
37538
  viabilityScore,
37277
37539
  searchPriority
37278
37540
  };
37279
- cache.set(cacheKey, "", result);
37541
+ if (!hasInconclusiveChecks) {
37542
+ cache.set(cacheKey, "", result);
37543
+ }
37280
37544
  return result;
37281
37545
  }
37546
+ /**
37547
+ * Fetch a single issue's core fields via REST and normalize them to the same
37548
+ * shape as a GraphQL prefetch (#169). The REST fallback path when no
37549
+ * prefetched core was supplied.
37550
+ */
37551
+ async fetchIssueCore(owner, repo, number4) {
37552
+ const { data: ghIssue } = await this.octokit.issues.get({
37553
+ owner,
37554
+ repo,
37555
+ issue_number: number4
37556
+ });
37557
+ return {
37558
+ id: ghIssue.id,
37559
+ title: ghIssue.title,
37560
+ body: ghIssue.body || "",
37561
+ state: ghIssue.state === "closed" ? "closed" : "open",
37562
+ labels: ghIssue.labels.map((l) => typeof l === "string" ? l : l.name || ""),
37563
+ commentCount: ghIssue.comments,
37564
+ createdAt: ghIssue.created_at,
37565
+ updatedAt: ghIssue.updated_at
37566
+ };
37567
+ }
37282
37568
  /**
37283
37569
  * Vet multiple issues in parallel with concurrency limit
37284
37570
  */
@@ -37289,13 +37575,23 @@ var IssueVetter = class {
37289
37575
  let rateLimitFailures = 0;
37290
37576
  let attemptedCount = 0;
37291
37577
  let firstAuthError = null;
37292
- for (const url2 of urls) {
37578
+ const uniqueUrls = [...new Set(urls)];
37579
+ const prefetched = await prefetchIssueCores(this.octokit, uniqueUrls.flatMap((url2) => {
37580
+ const p = parseGitHubUrl(url2);
37581
+ return p && p.type === "issues" ? [{ owner: p.owner, repo: p.repo, number: p.number }] : [];
37582
+ }));
37583
+ const prefetchFor = (url2) => {
37584
+ const p = parseGitHubUrl(url2);
37585
+ return p && p.type === "issues" ? prefetched.get(issueCoreKey(p.owner, p.repo, p.number)) : void 0;
37586
+ };
37587
+ for (const url2 of uniqueUrls) {
37293
37588
  if (candidates.length >= maxResults)
37294
37589
  break;
37295
37590
  if (firstAuthError)
37296
37591
  break;
37297
37592
  attemptedCount++;
37298
- const task = this.vetIssue(url2).then((candidate) => {
37593
+ const core = prefetchFor(url2);
37594
+ const task = this.vetIssue(url2, core ? { prefetched: core } : void 0).then((candidate) => {
37299
37595
  if (candidates.length < maxResults) {
37300
37596
  if (priority) {
37301
37597
  candidate.searchPriority = priority;
@@ -37311,7 +37607,7 @@ var IssueVetter = class {
37311
37607
  if (isRateLimitError(error48)) {
37312
37608
  rateLimitFailures++;
37313
37609
  }
37314
- warn(MODULE8, `Error vetting issue ${url2}:`, errorMessage(error48));
37610
+ warn(MODULE9, `Error vetting issue ${url2}:`, errorMessage(error48));
37315
37611
  }).finally(() => pending.delete(url2));
37316
37612
  pending.set(url2, task);
37317
37613
  if (pending.size >= MAX_CONCURRENT_VETTING) {
@@ -37321,13 +37617,13 @@ var IssueVetter = class {
37321
37617
  await Promise.allSettled(pending.values());
37322
37618
  if (firstAuthError) {
37323
37619
  if (candidates.length > 0) {
37324
- warn(MODULE8, `Auth failed mid-batch after ${candidates.length} successful vet(s) \u2014 discarding partial results`);
37620
+ warn(MODULE9, `Auth failed mid-batch after ${candidates.length} successful vet(s) \u2014 discarding partial results`);
37325
37621
  }
37326
37622
  throw firstAuthError;
37327
37623
  }
37328
37624
  const allFailed = failedVettingCount === attemptedCount && attemptedCount > 0;
37329
37625
  if (allFailed) {
37330
- warn(MODULE8, `All ${attemptedCount} issue(s) failed vetting. This may indicate a systemic issue (rate limit, auth, network).`);
37626
+ warn(MODULE9, `All ${attemptedCount} issue(s) failed vetting. This may indicate a systemic issue (rate limit, auth, network).`);
37331
37627
  }
37332
37628
  return {
37333
37629
  candidates: candidates.slice(0, maxResults),
@@ -37338,14 +37634,14 @@ var IssueVetter = class {
37338
37634
  };
37339
37635
 
37340
37636
  // ../core/dist/core/search-phases.js
37341
- var MODULE9 = "search-phases";
37637
+ var MODULE10 = "search-phases";
37342
37638
  var GITHUB_MAX_BOOLEAN_OPS = 5;
37343
37639
  var INTER_QUERY_DELAY_MS = 2e3;
37344
37640
  function chunkLabels(labels, reservedOps = 0) {
37345
37641
  const maxPerChunk = GITHUB_MAX_BOOLEAN_OPS - reservedOps + 1;
37346
37642
  if (maxPerChunk < 1) {
37347
37643
  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.`);
37644
+ 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
37645
  }
37350
37646
  return [[]];
37351
37647
  }
@@ -37355,7 +37651,7 @@ function chunkLabels(labels, reservedOps = 0) {
37355
37651
  for (let i = 0; i < labels.length; i += maxPerChunk) {
37356
37652
  chunks.push(labels.slice(i, i + maxPerChunk));
37357
37653
  }
37358
- debug(MODULE9, `Split ${labels.length} labels into ${chunks.length} chunks (${reservedOps} ops reserved, max ${maxPerChunk} per chunk)`);
37654
+ debug(MODULE10, `Split ${labels.length} labels into ${chunks.length} chunks (${reservedOps} ops reserved, max ${maxPerChunk} per chunk)`);
37359
37655
  return chunks;
37360
37656
  }
37361
37657
  function buildLabelQuery(labels) {
@@ -37387,15 +37683,14 @@ function interleaveArrays(arrays) {
37387
37683
  return result;
37388
37684
  }
37389
37685
  var SEARCH_CACHE_TTL_MS = 15 * 60 * 1e3;
37390
- async function cachedSearchIssues(octokit, params) {
37391
- const cacheKey = `search:${params.q}:${params.sort}:${params.order}:${params.per_page}`;
37686
+ async function cachedSearchIssues(octokit, params, tracker = getSearchBudgetTracker()) {
37687
+ const cacheKey = versionedCacheKey(`search:${params.q}:${params.sort}:${params.order}:${params.per_page}`);
37392
37688
  const cache = getHttpCache();
37393
37689
  const cached2 = cache.getIfFresh(cacheKey, SEARCH_CACHE_TTL_MS);
37394
37690
  if (cached2) {
37395
- debug(MODULE9, `Search cache hit for query`);
37691
+ debug(MODULE10, `Search cache hit for query`);
37396
37692
  return cached2;
37397
37693
  }
37398
- const tracker = getSearchBudgetTracker();
37399
37694
  await tracker.waitForBudget();
37400
37695
  let data;
37401
37696
  try {
@@ -37407,7 +37702,7 @@ async function cachedSearchIssues(octokit, params) {
37407
37702
  if (data.items.length > 0) {
37408
37703
  cache.set(cacheKey, "", data);
37409
37704
  } else {
37410
- debug(MODULE9, `Skipping cache for empty search result (possible rate limit artifact)`);
37705
+ debug(MODULE10, `Skipping cache for empty search result (possible rate limit artifact)`);
37411
37706
  }
37412
37707
  return data;
37413
37708
  }
@@ -37455,10 +37750,10 @@ async function fetchIssuesFromMaintainedRepos(octokit, repos, minStars, maxResul
37455
37750
  if (getHttpStatusCode(error48) === 401)
37456
37751
  throw error48;
37457
37752
  if (isRateLimitError(error48)) {
37458
- warn(MODULE9, `Rate limit hit fetching issues from ${repoFullName}:`, errorMessage(error48));
37753
+ warn(MODULE10, `Rate limit hit fetching issues from ${repoFullName}:`, errorMessage(error48));
37459
37754
  break;
37460
37755
  }
37461
- warn(MODULE9, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
37756
+ warn(MODULE10, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
37462
37757
  }
37463
37758
  }
37464
37759
  return items;
@@ -37475,16 +37770,27 @@ async function fetchIssuesFromKnownRepos(octokit, vetter, repos, labels, maxResu
37475
37770
  const repoFullName = repos[i];
37476
37771
  const [owner, repo] = repoFullName.split("/");
37477
37772
  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);
37773
+ const labelFilters = labels.length > 0 ? labels : [void 0];
37774
+ const seenUrls = /* @__PURE__ */ new Set();
37775
+ const rawIssues = [];
37776
+ for (const label of labelFilters) {
37777
+ const response = await octokit.issues.listForRepo({
37778
+ owner,
37779
+ repo,
37780
+ state: "open",
37781
+ sort: "created",
37782
+ direction: "desc",
37783
+ per_page: 5,
37784
+ ...label !== void 0 ? { labels: label } : {}
37785
+ });
37786
+ for (const issue2 of response.data) {
37787
+ if (seenUrls.has(issue2.html_url))
37788
+ continue;
37789
+ seenUrls.add(issue2.html_url);
37790
+ rawIssues.push(issue2);
37791
+ }
37792
+ }
37793
+ const issuesOnly = rawIssues.filter((item) => !("pull_request" in item) && !item.assignee);
37488
37794
  const mapped = issuesOnly.map((issue2) => ({
37489
37795
  html_url: issue2.html_url,
37490
37796
  repository_url: `https://api.github.com/repos/${repoFullName}`,
@@ -37509,17 +37815,17 @@ async function fetchIssuesFromKnownRepos(octokit, vetter, repos, labels, maxResu
37509
37815
  if (isRateLimitError(error48)) {
37510
37816
  rateLimitFailures++;
37511
37817
  }
37512
- warn(MODULE9, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
37818
+ warn(MODULE10, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
37513
37819
  }
37514
37820
  }
37515
37821
  const allReposFailed = failedRepos === repos.length && repos.length > 0;
37516
37822
  const rateLimitHit = rateLimitFailures > 0;
37517
37823
  if (allReposFailed) {
37518
- warn(MODULE9, `All ${repos.length} repo(s) failed for ${priority} phase. This may indicate a systemic issue (rate limit, auth, network).`);
37824
+ warn(MODULE10, `All ${repos.length} repo(s) failed for ${priority} phase. This may indicate a systemic issue (rate limit, auth, network).`);
37519
37825
  }
37520
37826
  return { candidates, allReposFailed, rateLimitHit };
37521
37827
  }
37522
- async function searchWithChunkedLabels(octokit, labels, reservedOps, buildQuery, perPage) {
37828
+ async function searchWithChunkedLabels(octokit, labels, reservedOps, buildQuery, perPage, tracker = getSearchBudgetTracker()) {
37523
37829
  const labelChunks = chunkLabels(labels, reservedOps);
37524
37830
  const seenUrls = /* @__PURE__ */ new Set();
37525
37831
  const allItems = [];
@@ -37532,7 +37838,7 @@ async function searchWithChunkedLabels(octokit, labels, reservedOps, buildQuery,
37532
37838
  sort: "created",
37533
37839
  order: "desc",
37534
37840
  per_page: perPage
37535
- });
37841
+ }, tracker);
37536
37842
  for (const item of data.items) {
37537
37843
  if (!seenUrls.has(item.html_url)) {
37538
37844
  seenUrls.add(item.html_url);
@@ -37551,14 +37857,14 @@ function buildLanguageVariants(languages, isAnyLanguage, hasLabels) {
37551
37857
  return [languages.map((l) => `language:${l}`).join(" ")];
37552
37858
  return languages.map((l) => `language:${l}`);
37553
37859
  }
37554
- async function searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, labels, buildBaseQuery, perPage) {
37860
+ async function searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, labels, buildBaseQuery, perPage, tracker = getSearchBudgetTracker()) {
37555
37861
  const langVariants = buildLanguageVariants(languages, isAnyLanguage, labels.length > 0);
37556
37862
  const seenUrls = /* @__PURE__ */ new Set();
37557
37863
  const allItems = [];
37558
37864
  for (let i = 0; i < langVariants.length; i++) {
37559
37865
  if (i > 0)
37560
37866
  await sleep(INTER_QUERY_DELAY_MS);
37561
- const items = await searchWithChunkedLabels(octokit, labels, 0, (labelQ) => `${buildBaseQuery(langVariants[i])} ${labelQ}`.replace(/ +/g, " ").trim(), perPage);
37867
+ const items = await searchWithChunkedLabels(octokit, labels, 0, (labelQ) => `${buildBaseQuery(langVariants[i])} ${labelQ}`.replace(/ +/g, " ").trim(), perPage, tracker);
37562
37868
  for (const item of items) {
37563
37869
  if (!seenUrls.has(item.html_url)) {
37564
37870
  seenUrls.add(item.html_url);
@@ -37572,7 +37878,7 @@ async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets,
37572
37878
  const spamRepos = detectLabelFarmingRepos(items);
37573
37879
  if (spamRepos.size > 0) {
37574
37880
  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(", ")}`);
37881
+ debug(MODULE10, `[SPAM_FILTER] Filtered ${spamCount} issues from ${spamRepos.size} label-farming repos: ${[...spamRepos].join(", ")}`);
37576
37882
  }
37577
37883
  const itemsToVet = filterIssues(items).filter((item) => {
37578
37884
  const repoFullName = extractRepoFromUrl(item.repository_url);
@@ -37583,7 +37889,7 @@ async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets,
37583
37889
  return excludedRepoSets.every((s) => !s.has(repoFullName));
37584
37890
  }).slice(0, remainingNeeded * 2);
37585
37891
  if (itemsToVet.length === 0) {
37586
- debug(MODULE9, `[${phaseLabel}] All ${items.length} items filtered before vetting`);
37892
+ debug(MODULE10, `[${phaseLabel}] All ${items.length} items filtered before vetting`);
37587
37893
  return { candidates: [], allVetFailed: false, rateLimitHit: false };
37588
37894
  }
37589
37895
  const { candidates: results, allFailed: allVetFailed, rateLimitHit } = await vetter.vetIssuesParallel(itemsToVet.map((i) => i.html_url), remainingNeeded, "normal");
@@ -37595,7 +37901,7 @@ async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets,
37595
37901
  });
37596
37902
  const starFilteredCount = results.length - starFiltered.length;
37597
37903
  if (starFilteredCount > 0) {
37598
- debug(MODULE9, `[STAR_FILTER] Filtered ${starFilteredCount} ${phaseLabel} candidates below ${minStars} stars`);
37904
+ debug(MODULE10, `[STAR_FILTER] Filtered ${starFilteredCount} ${phaseLabel} candidates below ${minStars} stars`);
37599
37905
  }
37600
37906
  return { candidates: starFiltered, allVetFailed, rateLimitHit };
37601
37907
  }
@@ -37603,28 +37909,50 @@ async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets,
37603
37909
  // ../core/dist/core/personalization.js
37604
37910
  var REPO_BOOST = 20;
37605
37911
  var LANGUAGE_BOOST = 10;
37606
- function annotateBoost(candidates, preferLanguages, preferRepos) {
37607
- const langSet = new Set((preferLanguages ?? []).map((l) => l.trim().toLowerCase()).filter(Boolean));
37608
- const repoSet = new Set((preferRepos ?? []).map((r) => r.trim()).filter(Boolean));
37609
- if (langSet.size === 0 && repoSet.size === 0)
37610
- return;
37611
- for (const c of candidates) {
37912
+ var ISSUE_TYPE_BOOST = 10;
37913
+ var AVOID_PENALTY = 15;
37914
+ function boostScoreOf(candidate) {
37915
+ return candidate.personalization?.kind === "boosted" ? candidate.personalization.score : 0;
37916
+ }
37917
+ function normalizeSet(values) {
37918
+ return new Set((values ?? []).map((v) => v.trim().toLowerCase()).filter(Boolean));
37919
+ }
37920
+ function annotateBoost(candidates, bias = {}) {
37921
+ const langSet = normalizeSet(bias.preferLanguages);
37922
+ const repoSet = normalizeSet(bias.preferRepos);
37923
+ const avoidSet = normalizeSet(bias.avoidRepos);
37924
+ const typeSet = normalizeSet(bias.boostIssueTypes);
37925
+ if (langSet.size === 0 && repoSet.size === 0 && avoidSet.size === 0 && typeSet.size === 0) {
37926
+ return candidates;
37927
+ }
37928
+ return candidates.map((c) => {
37612
37929
  let score = 0;
37613
37930
  const reasons = [];
37614
- if (repoSet.size > 0 && repoSet.has(c.issue.repo)) {
37931
+ const repoLower = c.issue.repo.toLowerCase();
37932
+ if (repoSet.size > 0 && repoSet.has(repoLower)) {
37615
37933
  score += REPO_BOOST;
37616
37934
  reasons.push(`repo affinity: ${c.issue.repo}`);
37617
37935
  }
37618
- const lang = c.projectHealth.language;
37936
+ const lang = c.projectHealth.checkFailed ? null : c.projectHealth.language;
37619
37937
  if (langSet.size > 0 && lang && langSet.has(lang.toLowerCase())) {
37620
37938
  score += LANGUAGE_BOOST;
37621
37939
  reasons.push(`language match: ${lang}`);
37622
37940
  }
37623
- if (score > 0) {
37624
- c.boostScore = score;
37625
- c.boostReasons = reasons;
37941
+ if (typeSet.size > 0) {
37942
+ const matched = c.issue.labels.find((l) => typeSet.has(l.toLowerCase()));
37943
+ if (matched) {
37944
+ score += ISSUE_TYPE_BOOST;
37945
+ reasons.push(`issue type: ${matched}`);
37946
+ }
37947
+ }
37948
+ if (avoidSet.size > 0 && avoidSet.has(repoLower)) {
37949
+ score -= AVOID_PENALTY;
37950
+ reasons.push(`avoided repo: ${c.issue.repo}`);
37626
37951
  }
37627
- }
37952
+ if (reasons.length === 0)
37953
+ return c;
37954
+ return { ...c, personalization: { kind: "boosted", score, reasons } };
37955
+ });
37628
37956
  }
37629
37957
  function applyDiversityRatio(candidates, maxResults, diversityRatio) {
37630
37958
  if (maxResults <= 0)
@@ -37649,10 +37977,9 @@ function applyDiversityRatio(candidates, maxResults, diversityRatio) {
37649
37977
  break;
37650
37978
  if (seen.has(c.issue.url))
37651
37979
  continue;
37652
- if (c.boostScore && c.boostScore > 0)
37980
+ if (boostScoreOf(c) !== 0)
37653
37981
  continue;
37654
- c.diversitySlot = true;
37655
- picks.push(c);
37982
+ picks.push({ ...c, personalization: { kind: "diversity" } });
37656
37983
  seen.add(c.issue.url);
37657
37984
  }
37658
37985
  for (const c of candidates) {
@@ -37667,7 +37994,7 @@ function applyDiversityRatio(candidates, maxResults, diversityRatio) {
37667
37994
  }
37668
37995
 
37669
37996
  // ../core/dist/core/issue-discovery.js
37670
- var MODULE10 = "issue-discovery";
37997
+ var MODULE11 = "issue-discovery";
37671
37998
  var LOW_BUDGET_THRESHOLD = 20;
37672
37999
  var CRITICAL_BUDGET_THRESHOLD = 10;
37673
38000
  function buildIssueFilter(config2) {
@@ -37676,16 +38003,17 @@ function buildIssueFilter(config2) {
37676
38003
  const repoFullName = extractRepoFromUrl(item.repository_url);
37677
38004
  if (!repoFullName)
37678
38005
  return false;
37679
- if (config2.excludedRepos.has(repoFullName))
38006
+ const repoLower = repoFullName.toLowerCase();
38007
+ if (config2.excludedRepos.has(repoLower))
37680
38008
  return false;
37681
38009
  if (config2.excludeOrgs.size > 0) {
37682
- const orgName = repoFullName.split("/")[0]?.toLowerCase();
38010
+ const orgName = repoLower.split("/")[0];
37683
38011
  if (orgName && config2.excludeOrgs.has(orgName))
37684
38012
  return false;
37685
38013
  }
37686
- if (config2.aiBlocklisted.has(repoFullName))
38014
+ if (config2.aiBlocklisted.has(repoLower))
37687
38015
  return false;
37688
- if (config2.lowScoringRepos.has(repoFullName))
38016
+ if (config2.lowScoringRepos.has(repoLower))
37689
38017
  return false;
37690
38018
  if (config2.skippedUrls.has(item.html_url))
37691
38019
  return false;
@@ -37700,9 +38028,9 @@ function buildIssueFilter(config2) {
37700
38028
  };
37701
38029
  }
37702
38030
  async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
37703
- info(MODULE10, `Phase 0: Searching issues in ${repos.length} merged-PR repos (no label filter)...`);
38031
+ info(MODULE11, `Phase 0: Searching issues in ${repos.length} merged-PR repos (no label filter)...`);
37704
38032
  const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, repos, [], maxResults, "merged_pr", filterIssues);
37705
- info(MODULE10, `Found ${candidates.length} candidates from merged-PR repos`);
38033
+ info(MODULE11, `Found ${candidates.length} candidates from merged-PR repos`);
37706
38034
  return {
37707
38035
  candidates,
37708
38036
  error: allReposFailed ? "All merged-PR repo fetches failed" : null,
@@ -37710,26 +38038,26 @@ async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
37710
38038
  };
37711
38039
  }
37712
38040
  async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssues) {
37713
- info(MODULE10, `Phase 1: Searching issues in ${repos.length} starred repos...`);
38041
+ info(MODULE11, `Phase 1: Searching issues in ${repos.length} starred repos...`);
37714
38042
  const phase1Labels = labels.slice(0, 3);
37715
38043
  const reposToSearch = repos.slice(0, 10);
37716
38044
  const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, reposToSearch, phase1Labels, maxResults, "starred", filterIssues);
37717
- info(MODULE10, `Found ${candidates.length} candidates from starred repos`);
38045
+ info(MODULE11, `Found ${candidates.length} candidates from starred repos`);
37718
38046
  return {
37719
38047
  candidates,
37720
38048
  error: allReposFailed ? "All starred repo fetches failed" : null,
37721
38049
  rateLimitHit
37722
38050
  };
37723
38051
  }
37724
- async function runPhase2(octokit, vetter, scopes, labels, configLabels, languages, isAnyLanguage, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
37725
- info(MODULE10, "Phase 2: General issue search...");
38052
+ async function runPhase2(octokit, vetter, scopes, labels, configLabels, languages, isAnyLanguage, maxResults, minStars, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues, tracker) {
38053
+ info(MODULE11, "Phase 2: General issue search...");
37726
38054
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
37727
38055
  const tierLabelGroups = [];
37728
38056
  if (scopes && scopes.length > 1) {
37729
38057
  for (const scope of scopes) {
37730
38058
  const scopeLabels = SCOPE_LABELS[scope] ?? [];
37731
38059
  if (scopeLabels.length === 0) {
37732
- warn(MODULE10, `Scope "${scope}" has no labels, skipping tier`);
38060
+ warn(MODULE11, `Scope "${scope}" has no labels, skipping tier`);
37733
38061
  continue;
37734
38062
  }
37735
38063
  tierLabelGroups.push({ tier: scope, tierLabels: scopeLabels });
@@ -37748,8 +38076,8 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37748
38076
  let rateLimitHit = false;
37749
38077
  for (const { tier, tierLabels } of tierLabelGroups) {
37750
38078
  try {
37751
- 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...`);
38079
+ const allItems = await searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, tierLabels, (langQ) => `is:issue is:open ${langQ} no:assignee`.replace(/ +/g, " ").trim(), budgetPerTier * 3, tracker);
38080
+ info(MODULE11, `Phase 2 [${tier}]: processing ${allItems.length} items...`);
37753
38081
  const { candidates: tierCandidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, allItems, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], budgetPerTier, minStars, `Phase 2 [${tier}]`);
37754
38082
  tierResults.push(tierCandidates);
37755
38083
  for (const c of tierCandidates)
@@ -37760,7 +38088,7 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37760
38088
  if (vetRateLimitHit) {
37761
38089
  rateLimitHit = true;
37762
38090
  }
37763
- info(MODULE10, `Found ${tierCandidates.length} candidates from ${tier} tier`);
38091
+ info(MODULE11, `Found ${tierCandidates.length} candidates from ${tier} tier`);
37764
38092
  } catch (err) {
37765
38093
  if (getHttpStatusCode(err) === 401)
37766
38094
  throw err;
@@ -37769,13 +38097,13 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37769
38097
  if (isRateLimitError(err)) {
37770
38098
  rateLimitHit = true;
37771
38099
  }
37772
- warn(MODULE10, `Error in ${tier} tier search: ${errMsg}`);
38100
+ warn(MODULE11, `Error in ${tier} tier search: ${errMsg}`);
37773
38101
  tierResults.push([]);
37774
38102
  }
37775
38103
  }
37776
38104
  const interleaved = interleaveArrays(tierResults);
37777
38105
  if (interleaved.length === 0 && error48) {
37778
- warn(MODULE10, `All ${tierLabelGroups.length} scope tiers failed in Phase 2: ${error48}`);
38106
+ warn(MODULE11, `All ${tierLabelGroups.length} scope tiers failed in Phase 2: ${error48}`);
37779
38107
  }
37780
38108
  return {
37781
38109
  candidates: interleaved.slice(0, maxResults),
@@ -37783,18 +38111,18 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, language
37783
38111
  rateLimitHit
37784
38112
  };
37785
38113
  }
37786
- async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, starredRepos, existingCandidates, filterIssues) {
37787
- info(MODULE10, "Phase 3: Searching actively maintained repos...");
38114
+ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, starredRepos, existingCandidates, filterIssues, tracker) {
38115
+ info(MODULE11, "Phase 3: Searching actively maintained repos...");
37788
38116
  const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
37789
38117
  const eligibleStarred = starredRepos.filter((r) => !phase0RepoSet.has(r) && !seenRepos.has(r));
37790
38118
  if (eligibleStarred.length > 0) {
37791
- info(MODULE10, `Phase 3: Checking ${eligibleStarred.length} starred repos via REST API...`);
38119
+ info(MODULE11, `Phase 3: Checking ${eligibleStarred.length} starred repos via REST API...`);
37792
38120
  const restItems = await fetchIssuesFromMaintainedRepos(octokit, eligibleStarred.slice(0, 15), minStars, maxResults);
37793
38121
  if (restItems.length > 0) {
37794
38122
  try {
37795
38123
  const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, restItems, filterIssues, [phase0RepoSet, seenRepos], maxResults, minStars, "Phase 3 (REST)");
37796
38124
  if (candidates.length > 0) {
37797
- info(MODULE10, `Found ${candidates.length} candidates from maintained-repo REST search`);
38125
+ info(MODULE11, `Found ${candidates.length} candidates from maintained-repo REST search`);
37798
38126
  return {
37799
38127
  candidates,
37800
38128
  error: allVetFailed ? "all vetting failed" : null,
@@ -37804,11 +38132,11 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37804
38132
  } catch (error48) {
37805
38133
  if (getHttpStatusCode(error48) === 401)
37806
38134
  throw error48;
37807
- warn(MODULE10, `Phase 3 REST vetting failed, falling back to Search API:`, errorMessage(error48));
38135
+ warn(MODULE11, `Phase 3 REST vetting failed, falling back to Search API:`, errorMessage(error48));
37808
38136
  }
37809
38137
  }
37810
38138
  }
37811
- info(MODULE10, "Phase 3: Falling back to Search API...");
38139
+ info(MODULE11, "Phase 3: Falling back to Search API...");
37812
38140
  const thirtyDaysAgo = /* @__PURE__ */ new Date();
37813
38141
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
37814
38142
  const pushedSince = thirtyDaysAgo.toISOString().split("T")[0];
@@ -37821,10 +38149,10 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37821
38149
  sort: "updated",
37822
38150
  order: "desc",
37823
38151
  per_page: maxResults * 3
37824
- });
37825
- info(MODULE10, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
38152
+ }, tracker);
38153
+ info(MODULE11, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
37826
38154
  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`);
38155
+ info(MODULE11, `Found ${candidates.length} candidates from maintained-repo search`);
37828
38156
  return {
37829
38157
  candidates,
37830
38158
  error: allVetFailed ? "all vetting failed" : null,
@@ -37834,7 +38162,7 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37834
38162
  if (getHttpStatusCode(error48) === 401)
37835
38163
  throw error48;
37836
38164
  const errMsg = errorMessage(error48);
37837
- warn(MODULE10, `Error in maintained-repo search: ${errMsg}`);
38165
+ warn(MODULE11, `Error in maintained-repo search: ${errMsg}`);
37838
38166
  return {
37839
38167
  candidates: [],
37840
38168
  error: errMsg,
@@ -37848,19 +38176,26 @@ var IssueDiscovery = class {
37848
38176
  octokit;
37849
38177
  githubToken;
37850
38178
  vetter;
38179
+ budgetTracker;
37851
38180
  /** Set after searchIssues() runs if rate limits affected the search (low pre-flight quota or mid-search rate limit hits). */
37852
38181
  rateLimitWarning = null;
37853
38182
  /**
37854
38183
  * @param githubToken - GitHub personal access token or token from `gh auth token`
37855
38184
  * @param preferences - User's search preferences (languages, labels, scopes, etc.)
37856
38185
  * @param stateReader - Read-only interface for accessing scout state (merged PRs, starred repos, etc.)
38186
+ * @param budgetTracker - Search budget tracker. Defaults to the shared
38187
+ * singleton so existing callers behave identically. A long-lived host
38188
+ * serving concurrent searches can inject a per-search instance so one
38189
+ * search's init() no longer resets the budget state of another (the
38190
+ * shared-singleton concurrency hazard, #156).
37857
38191
  */
37858
- constructor(githubToken, preferences, stateReader) {
38192
+ constructor(githubToken, preferences, stateReader, budgetTracker = getSearchBudgetTracker()) {
37859
38193
  this.preferences = preferences;
37860
38194
  this.stateReader = stateReader;
37861
38195
  this.githubToken = githubToken;
37862
38196
  this.octokit = getOctokit(githubToken);
37863
- this.vetter = new IssueVetter(this.octokit, this.stateReader);
38197
+ this.budgetTracker = budgetTracker;
38198
+ this.vetter = new IssueVetter(this.octokit, this.stateReader, this.budgetTracker);
37864
38199
  }
37865
38200
  /**
37866
38201
  * Get starred repos from the state reader.
@@ -37900,16 +38235,29 @@ var IssueDiscovery = class {
37900
38235
  const labels = options.labels || (scopes ? buildEffectiveLabels(scopes, config2.labels) : config2.labels);
37901
38236
  const maxResults = options.maxResults || 10;
37902
38237
  const minStars = config2.minStars ?? 50;
37903
- const interPhaseDelay = config2.interPhaseDelayMs ?? 3e4;
38238
+ const interPhaseDelay = options.interPhaseDelayMs ?? config2.interPhaseDelayMs ?? 3e4;
37904
38239
  const ALL_STRATEGIES = CONCRETE_STRATEGIES;
37905
- const rawStrategies = options.strategies ?? config2.defaultStrategy ?? ["all"];
38240
+ const pickStrategies = (...candidates) => candidates.find((c) => c && c.length > 0) ?? ["all"];
38241
+ const rawStrategies = pickStrategies(options.strategies, config2.defaultStrategy);
37906
38242
  const enabledStrategies = new Set(rawStrategies.includes("all") ? ALL_STRATEGIES : rawStrategies);
37907
38243
  const strategiesUsed = [];
37908
38244
  const allCandidates = [];
37909
38245
  const phaseErrors = {};
37910
38246
  let rateLimitHitDuringSearch = false;
38247
+ const applyInterPhaseDelay = async () => {
38248
+ if (interPhaseDelay > 0) {
38249
+ info(MODULE11, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
38250
+ await sleep(interPhaseDelay);
38251
+ }
38252
+ };
38253
+ const recordPhaseResult = (key, result) => {
38254
+ allCandidates.push(...result.candidates);
38255
+ phaseErrors[key] = result.error;
38256
+ if (result.rateLimitHit)
38257
+ rateLimitHitDuringSearch = true;
38258
+ };
37911
38259
  this.rateLimitWarning = null;
37912
- const tracker = getSearchBudgetTracker();
38260
+ const tracker = this.budgetTracker;
37913
38261
  let searchBudget = LOW_BUDGET_THRESHOLD - 1;
37914
38262
  try {
37915
38263
  const rateLimit = await checkRateLimit(this.githubToken);
@@ -37918,18 +38266,18 @@ var IssueDiscovery = class {
37918
38266
  if (rateLimit.remaining < 5) {
37919
38267
  const resetTime = new Date(rateLimit.resetAt).toLocaleTimeString("en-US", { hour12: false });
37920
38268
  this.rateLimitWarning = `GitHub search API quota low (${rateLimit.remaining}/${rateLimit.limit} remaining, resets at ${resetTime}). Search may be slow.`;
37921
- warn(MODULE10, this.rateLimitWarning);
38269
+ warn(MODULE11, this.rateLimitWarning);
37922
38270
  }
37923
38271
  if (searchBudget < CRITICAL_BUDGET_THRESHOLD) {
37924
- info(MODULE10, `Search budget critical (${searchBudget} remaining) \u2014 running only Phase 0`);
38272
+ info(MODULE11, `Search budget critical (${searchBudget} remaining) \u2014 running only Phase 0`);
37925
38273
  } else if (searchBudget < LOW_BUDGET_THRESHOLD) {
37926
- info(MODULE10, `Search budget low (${searchBudget} remaining) \u2014 skipping heavy phases (2, 3)`);
38274
+ info(MODULE11, `Search budget low (${searchBudget} remaining) \u2014 skipping heavy phases (2, 3)`);
37927
38275
  }
37928
38276
  } catch (error48) {
37929
38277
  if (getHttpStatusCode(error48) === 401)
37930
38278
  throw error48;
37931
38279
  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));
38280
+ warn(MODULE11, "Could not check rate limit \u2014 using conservative budget, skipping heavy phases:", errorMessage(error48));
37933
38281
  }
37934
38282
  if (searchBudget <= 0) {
37935
38283
  this.rateLimitWarning = "GitHub search API quota exhausted. Try again after the rate limit resets.";
@@ -37939,15 +38287,15 @@ var IssueDiscovery = class {
37939
38287
  const openPRRepos = this.stateReader.getReposWithOpenPRs();
37940
38288
  const starredRepos = this.getStarredRepos();
37941
38289
  const starredRepoSet = new Set(starredRepos);
37942
- const lowScoringRepos = new Set(this.deriveLowScoringRepos(config2.minRepoScoreThreshold));
38290
+ const lowScoringRepos = new Set(this.deriveLowScoringRepos(config2.minRepoScoreThreshold).map((r) => r.toLowerCase()));
37943
38291
  const isAnyLanguage = languages.some((l) => l.toLowerCase() === "any");
37944
38292
  const langQuery = isAnyLanguage ? "" : languages.map((l) => `language:${l}`).join(" ");
37945
- const aiBlocklisted = new Set(config2.aiPolicyBlocklist);
38293
+ const aiBlocklisted = new Set(config2.aiPolicyBlocklist.map((r) => r.toLowerCase()));
37946
38294
  if (aiBlocklisted.size > 0) {
37947
- debug(MODULE10, `[AI_POLICY_FILTER] Filtering issues from ${aiBlocklisted.size} blocklisted repo(s): ${[...aiBlocklisted].join(", ")}`);
38295
+ debug(MODULE11, `[AI_POLICY_FILTER] Filtering issues from ${aiBlocklisted.size} blocklisted repo(s): ${[...aiBlocklisted].join(", ")}`);
37948
38296
  }
37949
38297
  const filterIssues = buildIssueFilter({
37950
- excludedRepos: new Set(config2.excludeRepos),
38298
+ excludedRepos: new Set(config2.excludeRepos.map((r) => r.toLowerCase())),
37951
38299
  excludeOrgs: new Set((config2.excludeOrgs ?? []).map((o) => o.toLowerCase())),
37952
38300
  aiBlocklisted,
37953
38301
  lowScoringRepos,
@@ -37971,67 +38319,47 @@ var IssueDiscovery = class {
37971
38319
  const remaining = maxResults - allCandidates.length;
37972
38320
  if (remaining > 0) {
37973
38321
  const result = await runPhase0(this.octokit, this.vetter, phase0Repos, remaining, filterIssues);
37974
- allCandidates.push(...result.candidates);
37975
- phaseErrors["0"] = result.error;
37976
- if (result.rateLimitHit)
37977
- rateLimitHitDuringSearch = true;
38322
+ recordPhaseResult("0", result);
37978
38323
  }
37979
38324
  strategiesUsed.push("merged");
37980
38325
  }
37981
38326
  if (allCandidates.length < maxResults && starredRepos.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD && enabledStrategies.has("starred")) {
37982
- if (interPhaseDelay > 0) {
37983
- info(MODULE10, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
37984
- await sleep(interPhaseDelay);
37985
- }
38327
+ await applyInterPhaseDelay();
37986
38328
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
37987
38329
  if (reposToSearch.length > 0) {
37988
38330
  const remaining = maxResults - allCandidates.length;
37989
38331
  if (remaining > 0) {
37990
38332
  const result = await runPhase1(this.octokit, this.vetter, reposToSearch, labels, remaining, filterIssues);
37991
- allCandidates.push(...result.candidates);
37992
- phaseErrors["1"] = result.error;
37993
- if (result.rateLimitHit)
37994
- rateLimitHitDuringSearch = true;
38333
+ recordPhaseResult("1", result);
38334
+ strategiesUsed.push("starred");
37995
38335
  }
37996
38336
  }
37997
- strategiesUsed.push("starred");
37998
38337
  }
37999
- const broadDelay = config2.broadPhaseDelayMs ?? 9e4;
38000
- const skipThreshold = config2.skipBroadWhenSufficientResults ?? 15;
38338
+ const broadDelay = options.broadPhaseDelayMs ?? config2.broadPhaseDelayMs ?? 9e4;
38339
+ const configuredSkipThreshold = config2.skipBroadWhenSufficientResults ?? 8;
38340
+ const skipThreshold = configuredSkipThreshold > 0 ? Math.min(configuredSkipThreshold, maxResults - 1) : 0;
38001
38341
  if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD && enabledStrategies.has("broad")) {
38002
38342
  if (skipThreshold > 0 && allCandidates.length >= skipThreshold) {
38003
- info(MODULE10, `Skipping broad search: already found ${allCandidates.length} candidates (threshold: ${skipThreshold})`);
38343
+ info(MODULE11, `Skipping broad search: already found ${allCandidates.length} candidates (threshold: ${skipThreshold})`);
38004
38344
  } else {
38005
- if (interPhaseDelay > 0) {
38006
- info(MODULE10, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
38007
- await sleep(interPhaseDelay);
38008
- }
38345
+ await applyInterPhaseDelay();
38009
38346
  if (allCandidates.length > 0 && broadDelay > 0) {
38010
- info(MODULE10, `Waiting ${(broadDelay / 1e3).toFixed(0)}s for rate limit cooldown before broad search...`);
38347
+ info(MODULE11, `Waiting ${(broadDelay / 1e3).toFixed(0)}s for rate limit cooldown before broad search...`);
38011
38348
  await sleep(broadDelay);
38012
38349
  } else if (allCandidates.length === 0) {
38013
- info(MODULE10, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
38350
+ info(MODULE11, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
38014
38351
  }
38015
38352
  const remaining = maxResults - allCandidates.length;
38016
- const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config2.labels, languages, isAnyLanguage, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
38017
- allCandidates.push(...result.candidates);
38018
- phaseErrors["2"] = result.error;
38019
- if (result.rateLimitHit)
38020
- rateLimitHitDuringSearch = true;
38353
+ const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config2.labels, languages, isAnyLanguage, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues, tracker);
38354
+ recordPhaseResult("2", result);
38355
+ strategiesUsed.push("broad");
38021
38356
  }
38022
- strategiesUsed.push("broad");
38023
38357
  }
38024
38358
  if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD && enabledStrategies.has("maintained")) {
38025
- if (interPhaseDelay > 0) {
38026
- info(MODULE10, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
38027
- await sleep(interPhaseDelay);
38028
- }
38359
+ await applyInterPhaseDelay();
38029
38360
  const remaining = maxResults - allCandidates.length;
38030
- const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config2.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, starredRepos, allCandidates, filterIssues);
38031
- allCandidates.push(...result.candidates);
38032
- phaseErrors["3"] = result.error;
38033
- if (result.rateLimitHit)
38034
- rateLimitHitDuringSearch = true;
38361
+ const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config2.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, starredRepos, allCandidates, filterIssues, tracker);
38362
+ recordPhaseResult("3", result);
38035
38363
  strategiesUsed.push("maintained");
38036
38364
  }
38037
38365
  const phasesSkippedForBudget = searchBudget < LOW_BUDGET_THRESHOLD;
@@ -38058,8 +38386,13 @@ var IssueDiscovery = class {
38058
38386
  if (rateLimitHitDuringSearch || phasesSkippedForBudget) {
38059
38387
  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
38388
  }
38061
- annotateBoost(allCandidates, options.preferLanguages, options.preferRepos);
38062
- allCandidates.sort((a, b) => {
38389
+ const ranked = annotateBoost(allCandidates, {
38390
+ preferLanguages: options.preferLanguages,
38391
+ preferRepos: options.preferRepos,
38392
+ avoidRepos: options.avoidRepos,
38393
+ boostIssueTypes: options.boostIssueTypes
38394
+ });
38395
+ ranked.sort((a, b) => {
38063
38396
  const priorityOrder = {
38064
38397
  merged_pr: 0,
38065
38398
  starred: 1,
@@ -38072,14 +38405,14 @@ var IssueDiscovery = class {
38072
38405
  const recDiff = recommendationOrder[a.recommendation] - recommendationOrder[b.recommendation];
38073
38406
  if (recDiff !== 0)
38074
38407
  return recDiff;
38075
- const boostDiff = (b.boostScore ?? 0) - (a.boostScore ?? 0);
38408
+ const boostDiff = boostScoreOf(b) - boostScoreOf(a);
38076
38409
  if (boostDiff !== 0)
38077
38410
  return boostDiff;
38078
38411
  return b.viabilityScore - a.viabilityScore;
38079
38412
  });
38080
- const capped = applyPerRepoCap(allCandidates, 2);
38413
+ const capped = applyPerRepoCap(ranked, 2);
38081
38414
  const finalPicks = applyDiversityRatio(capped, maxResults, options.diversityRatio ?? 0);
38082
- info(MODULE10, `Search complete: ${tracker.getTotalCalls()} Search API calls used, ${finalPicks.length} candidates returned`);
38415
+ info(MODULE11, `Search complete: ${tracker.getTotalCalls()} Search API calls used, ${finalPicks.length} candidates returned`);
38083
38416
  return { candidates: finalPicks, strategiesUsed };
38084
38417
  }
38085
38418
  /**
@@ -38112,7 +38445,6 @@ var IssueDiscovery = class {
38112
38445
  };
38113
38446
 
38114
38447
  // ../core/dist/core/roadmap.js
38115
- var MODULE11 = "roadmap";
38116
38448
  var CACHE_TTL_MS2 = 60 * 60 * 1e3;
38117
38449
  var ROADMAP_PATHS = [
38118
38450
  "ROADMAP.md",
@@ -38166,24 +38498,27 @@ async function fetchRoadmapIssueRefs(octokit, owner, repo) {
38166
38498
  if (cached2 && Date.now() - cached2.fetchedAt < CACHE_TTL_MS2) {
38167
38499
  return cached2.refs;
38168
38500
  }
38501
+ const inflight = roadmapInflight.get(cacheKey);
38502
+ if (inflight)
38503
+ return inflight;
38504
+ const promise2 = fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey);
38505
+ roadmapInflight.set(cacheKey, promise2);
38506
+ try {
38507
+ return await promise2;
38508
+ } finally {
38509
+ roadmapInflight.delete(cacheKey);
38510
+ }
38511
+ }
38512
+ var roadmapInflight = /* @__PURE__ */ new Map();
38513
+ async function fetchRoadmapIssueRefsUncached(octokit, owner, repo, cacheKey) {
38169
38514
  for (const path5 of ROADMAP_PATHS) {
38170
- try {
38171
- const { data } = await octokit.repos.getContent({ owner, repo, path: path5 });
38172
- if (!("content" in data))
38173
- continue;
38174
- const content = Buffer.from(data.content, "base64").toString("utf-8");
38175
- const refs = parseRoadmapIssueRefs(content, owner, repo);
38176
- roadmapCache.set(cacheKey, { refs, fetchedAt: Date.now() });
38177
- pruneCache2();
38178
- return refs;
38179
- } catch (err) {
38180
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38181
- throw err;
38182
- const status = getHttpStatusCode(err);
38183
- if (status === 404)
38184
- continue;
38185
- warn(MODULE11, `Unexpected error fetching ${path5} from ${owner}/${repo}: ${errorMessage(err)}`);
38186
- }
38515
+ const { text } = await probeRepoFile(octokit, owner, repo, path5);
38516
+ if (!text)
38517
+ continue;
38518
+ const refs = parseRoadmapIssueRefs(text, owner, repo);
38519
+ roadmapCache.set(cacheKey, { refs, fetchedAt: Date.now() });
38520
+ pruneCache2();
38521
+ return refs;
38187
38522
  }
38188
38523
  const empty = /* @__PURE__ */ new Set();
38189
38524
  roadmapCache.set(cacheKey, { refs: empty, fetchedAt: Date.now() });
@@ -38284,6 +38619,37 @@ function isFeatureIssue(item) {
38284
38619
  return false;
38285
38620
  return labels.some((l) => FEATURE_LABELS.includes(l));
38286
38621
  }
38622
+ async function vetAndClassify(item, vetter, roadmapRefs) {
38623
+ const labels = extractLabels(item);
38624
+ const hasMilestone = !!item.milestone;
38625
+ const reactions = item.reactions?.total_count ?? 0;
38626
+ const comments = item.comments ?? 0;
38627
+ const wontfixNoContributor = item.created_at ? detectWontfixNoContributor({ labels, createdAt: item.created_at }) : false;
38628
+ const useRoadmap = roadmapRefs !== void 0;
38629
+ const onRoadmap = useRoadmap && typeof item.number === "number" && roadmapRefs.has(item.number);
38630
+ let candidate;
38631
+ try {
38632
+ candidate = await vetter.vetIssue(item.html_url, {
38633
+ featureSignals: {
38634
+ reactions,
38635
+ comments,
38636
+ hasMilestone,
38637
+ wontfixNoContributor,
38638
+ ...useRoadmap ? { onRoadmap } : {}
38639
+ }
38640
+ });
38641
+ } catch (err) {
38642
+ rethrowIfFatal(err);
38643
+ warn(MODULE12, `vet failed for ${item.html_url}: ${errorMessage(err)}`);
38644
+ return null;
38645
+ }
38646
+ const horizon = classifyHorizon({
38647
+ hasMilestone,
38648
+ labels,
38649
+ ...useRoadmap ? { isOnRoadmap: onRoadmap } : {}
38650
+ });
38651
+ return { ...candidate, horizon };
38652
+ }
38287
38653
  async function discoverFeatures(opts) {
38288
38654
  const anchorRepos = resolveAnchorRepos(opts.repoScores, opts.anchorThreshold);
38289
38655
  if (anchorRepos.length === 0) {
@@ -38316,42 +38682,15 @@ async function discoverFeatures(opts) {
38316
38682
  response = listResp;
38317
38683
  roadmapRefs = refs;
38318
38684
  } catch (err) {
38319
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38320
- throw err;
38685
+ rethrowIfFatal(err);
38321
38686
  warn(MODULE12, `failed to list issues for ${anchorRepos[i]}: ${errorMessage(err)}`);
38322
38687
  continue;
38323
38688
  }
38324
38689
  const items = response.data.filter((it) => !it.pull_request && !it.assignee && isFeatureIssue(it));
38325
38690
  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 });
38691
+ const candidate = await vetAndClassify(item, opts.vetter, roadmapRefs);
38692
+ if (candidate)
38693
+ candidates.push(candidate);
38355
38694
  }
38356
38695
  }
38357
38696
  const passing = candidates.filter((c) => c.viabilityScore >= MIN_VIABILITY_SCORE);
@@ -38372,10 +38711,8 @@ function buildBroadFeatureSearchQuery(opts) {
38372
38711
  for (const excl of FEATURE_EXCLUSION_LABELS) {
38373
38712
  parts.push(`-label:"${excl}"`);
38374
38713
  }
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})`);
38714
+ if (opts.language) {
38715
+ parts.push(`language:${opts.language}`);
38379
38716
  }
38380
38717
  for (const repo of opts.excludeRepos ?? []) {
38381
38718
  parts.push(`-repo:${repo}`);
@@ -38385,8 +38722,19 @@ function buildBroadFeatureSearchQuery(opts) {
38385
38722
  }
38386
38723
  return parts.join(" ");
38387
38724
  }
38725
+ function buildBroadFeatureSearchQueries(opts) {
38726
+ const base = {
38727
+ excludeRepos: opts.excludeRepos,
38728
+ excludeOrgs: opts.excludeOrgs
38729
+ };
38730
+ const languages = (opts.languages ?? []).filter((l) => l && l.toLowerCase() !== "any");
38731
+ if (languages.length === 0) {
38732
+ return [buildBroadFeatureSearchQuery(base)];
38733
+ }
38734
+ return languages.map((language) => buildBroadFeatureSearchQuery({ ...base, language }));
38735
+ }
38388
38736
  async function discoverFeaturesBroad(opts) {
38389
- const query = buildBroadFeatureSearchQuery({
38737
+ const queries = buildBroadFeatureSearchQueries({
38390
38738
  languages: opts.languages,
38391
38739
  excludeRepos: opts.excludeRepos,
38392
38740
  excludeOrgs: opts.excludeOrgs
@@ -38394,16 +38742,25 @@ async function discoverFeaturesBroad(opts) {
38394
38742
  const maxToVet = opts.maxToVet ?? DEFAULT_BROAD_MAX_TO_VET;
38395
38743
  let items;
38396
38744
  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));
38745
+ const merged = [];
38746
+ const seenUrls = /* @__PURE__ */ new Set();
38747
+ for (const query of queries) {
38748
+ const data = await cachedSearchIssues(opts.octokit, {
38749
+ q: query,
38750
+ sort: "interactions",
38751
+ order: "desc",
38752
+ per_page: maxToVet
38753
+ });
38754
+ for (const raw of data.items) {
38755
+ if (seenUrls.has(raw.html_url))
38756
+ continue;
38757
+ seenUrls.add(raw.html_url);
38758
+ merged.push(raw);
38759
+ }
38760
+ }
38761
+ items = merged.filter((it) => !it.pull_request && !it.assignee && isFeatureIssue(it)).slice(0, maxToVet);
38404
38762
  } catch (err) {
38405
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38406
- throw err;
38763
+ rethrowIfFatal(err);
38407
38764
  warn(MODULE12, `broad feature search failed: ${errorMessage(err)}`);
38408
38765
  return {
38409
38766
  quickWins: [],
@@ -38414,32 +38771,9 @@ async function discoverFeaturesBroad(opts) {
38414
38771
  }
38415
38772
  const candidates = [];
38416
38773
  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 });
38774
+ const candidate = await vetAndClassify(item, opts.vetter);
38775
+ if (candidate)
38776
+ candidates.push(candidate);
38443
38777
  }
38444
38778
  const passing = candidates.filter((c) => c.viabilityScore >= MIN_VIABILITY_SCORE);
38445
38779
  const split = splitByHorizon(passing, opts.count, opts.splitRatio);
@@ -38459,14 +38793,11 @@ var GIST_DESCRIPTION = "oss-scout-state";
38459
38793
  var GIST_FILENAME = "state.json";
38460
38794
  var GIST_ID_FILE = "gist-id";
38461
38795
  var CACHE_FILE = "state-cache.json";
38462
- var SEARCH_MAX_PAGES = 5;
38796
+ var SEARCH_MAX_PAGES = 10;
38463
38797
  function classifyDegradedReason(err) {
38464
38798
  if (isRateLimitError(err))
38465
38799
  return "rate_limit";
38466
38800
  const status = getHttpStatusCode(err);
38467
- if (status === 403 && errorMessage(err).toLowerCase().includes("abuse detection")) {
38468
- return "rate_limit";
38469
- }
38470
38801
  if (status !== void 0 && status >= 500 && status < 600)
38471
38802
  return "server";
38472
38803
  if (err && typeof err === "object" && "code" in err) {
@@ -38510,12 +38841,23 @@ var GistStateStore = class {
38510
38841
  * Push state to the gist. Also writes to local cache as fallback.
38511
38842
  */
38512
38843
  async push(state) {
38513
- this.writeCache(state);
38514
38844
  if (!this.gistId) {
38515
38845
  warn(MODULE13, "No gist ID \u2014 cannot push");
38846
+ this.writeCache(state);
38516
38847
  return false;
38517
38848
  }
38518
- const json2 = JSON.stringify(state, null, 2);
38849
+ let toWrite = state;
38850
+ try {
38851
+ const remote = await this.fetchGistState(this.gistId);
38852
+ if (remote) {
38853
+ toWrite = mergeStates(state, remote);
38854
+ }
38855
+ } catch (err) {
38856
+ rethrowIfFatal(err);
38857
+ warn(MODULE13, `Could not fetch gist before push, writing local snapshot: ${errorMessage(err)}`);
38858
+ }
38859
+ this.writeCache(toWrite);
38860
+ const json2 = JSON.stringify(toWrite, null, 2);
38519
38861
  if (json2.length > 9e5) {
38520
38862
  warn(MODULE13, `State too large for gist (${Math.round(json2.length / 1024)}KB). Consider clearing old results with 'oss-scout results clear'.`);
38521
38863
  return false;
@@ -38530,31 +38872,11 @@ var GistStateStore = class {
38530
38872
  debug(MODULE13, "State pushed to gist");
38531
38873
  return true;
38532
38874
  } catch (err) {
38533
- if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
38534
- throw err;
38875
+ rethrowIfFatal(err);
38535
38876
  warn(MODULE13, `Failed to push: ${errorMessage(err)}`);
38536
38877
  return false;
38537
38878
  }
38538
38879
  }
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
38880
  /** Get the current gist ID (if known). */
38559
38881
  getGistId() {
38560
38882
  return this.gistId;
@@ -38565,9 +38887,10 @@ var GistStateStore = class {
38565
38887
  if (cachedId) {
38566
38888
  debug(MODULE13, `Trying cached gist ID: ${cachedId}`);
38567
38889
  try {
38568
- const state = await this.fetchGistState(cachedId);
38569
- if (state) {
38890
+ const fetched = await this.fetchGistState(cachedId);
38891
+ if (fetched) {
38570
38892
  this.gistId = cachedId;
38893
+ const state = this.mergeCacheInto(fetched);
38571
38894
  this.writeCache(state);
38572
38895
  return { gistId: cachedId, state, created: false };
38573
38896
  }
@@ -38578,19 +38901,23 @@ var GistStateStore = class {
38578
38901
  }
38579
38902
  debug(MODULE13, "Cached gist ID invalid, searching...");
38580
38903
  }
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) {
38904
+ const search = await this.searchForGist();
38905
+ if (search.id) {
38906
+ debug(MODULE13, `Found gist via search: ${search.id}`);
38907
+ this.saveGistId(search.id);
38908
+ this.gistId = search.id;
38909
+ const fetched = await this.fetchGistState(search.id);
38910
+ if (fetched) {
38911
+ const state = this.mergeCacheInto(fetched);
38588
38912
  this.writeCache(state);
38589
- return { gistId: foundId, state, created: false };
38913
+ return { gistId: search.id, state, created: false };
38590
38914
  }
38591
- warn(MODULE13, `Found existing gist ${foundId} but content failed validation. Using local cache to avoid data loss.`);
38915
+ warn(MODULE13, `Found existing gist ${search.id} but content failed validation. Using local cache to avoid data loss.`);
38592
38916
  return this.bootstrapFromCache("unknown");
38593
38917
  }
38918
+ if (!search.exhaustive) {
38919
+ warn(MODULE13, `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.`);
38920
+ }
38594
38921
  debug(MODULE13, "No existing gist found, creating new one");
38595
38922
  const freshState = ScoutStateSchema.parse({ version: 1 });
38596
38923
  const newId = await this.createGist(freshState);
@@ -38632,12 +38959,17 @@ var GistStateStore = class {
38632
38959
  return null;
38633
38960
  try {
38634
38961
  const parsed = JSON.parse(file2.content);
38635
- return ScoutStateSchema.parse(parsed);
38962
+ return parseScoutState(parsed);
38636
38963
  } catch (err) {
38637
38964
  warn(MODULE13, `Gist content failed validation: ${errorMessage(err)}`);
38638
38965
  return null;
38639
38966
  }
38640
38967
  }
38968
+ /**
38969
+ * Scan the user's gists for the state gist. `exhaustive: false` means the
38970
+ * page cap was hit while pages were still full, so the account may hold
38971
+ * the state gist beyond the scan window.
38972
+ */
38641
38973
  async searchForGist() {
38642
38974
  for (let page = 1; page <= SEARCH_MAX_PAGES; page++) {
38643
38975
  const { data: gists } = await this.octokit.gists.list({
@@ -38645,12 +38977,14 @@ var GistStateStore = class {
38645
38977
  page
38646
38978
  });
38647
38979
  if (gists.length === 0)
38648
- break;
38980
+ return { id: null, exhaustive: true };
38649
38981
  const match = gists.find((g) => g.description === GIST_DESCRIPTION);
38650
38982
  if (match)
38651
- return match.id;
38983
+ return { id: match.id, exhaustive: true };
38984
+ if (gists.length < 100)
38985
+ return { id: null, exhaustive: true };
38652
38986
  }
38653
- return null;
38987
+ return { id: null, exhaustive: false };
38654
38988
  }
38655
38989
  async createGist(state) {
38656
38990
  const { data } = await this.octokit.gists.create({
@@ -38678,10 +39012,21 @@ var GistStateStore = class {
38678
39012
  saveGistId(id) {
38679
39013
  fs3.writeFileSync(getGistIdPath(), id + "\n", { mode: 384 });
38680
39014
  }
39015
+ /**
39016
+ * Merge the local state-cache into a freshly fetched gist state before it
39017
+ * overwrites the cache (#117). Without this, a prior failed push left its
39018
+ * only copy of the user's changes in state-cache.json, which the next
39019
+ * successful bootstrap silently clobbered. Tombstone-aware mergeStates
39020
+ * keeps both sides' real changes.
39021
+ */
39022
+ mergeCacheInto(fetched) {
39023
+ const cached2 = this.readCache();
39024
+ return cached2 ? mergeStates(cached2, fetched) : fetched;
39025
+ }
38681
39026
  readCache() {
38682
39027
  try {
38683
39028
  const raw = fs3.readFileSync(getCachePath(), "utf-8");
38684
- return ScoutStateSchema.parse(JSON.parse(raw));
39029
+ return parseScoutState(JSON.parse(raw));
38685
39030
  } catch (err) {
38686
39031
  const code = err?.code;
38687
39032
  if (code !== "ENOENT") {
@@ -38700,18 +39045,57 @@ var GistStateStore = class {
38700
39045
  }
38701
39046
  }
38702
39047
  };
39048
+ var TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1e3;
39049
+ function mergeTombstones(local, remote) {
39050
+ const cutoff = Date.now() - TOMBSTONE_TTL_MS;
39051
+ const byUrl = /* @__PURE__ */ new Map();
39052
+ for (const t of [...local, ...remote]) {
39053
+ const existing = byUrl.get(t.url);
39054
+ if (!existing || t.removedAt > existing.removedAt)
39055
+ byUrl.set(t.url, t);
39056
+ }
39057
+ return [...byUrl.values()].filter((t) => {
39058
+ const ts = new Date(t.removedAt).getTime();
39059
+ return !Number.isFinite(ts) || ts >= cutoff;
39060
+ });
39061
+ }
39062
+ function applyTombstones(items, tombstones, urlOf, touchedAtOf) {
39063
+ if (tombstones.length === 0)
39064
+ return items;
39065
+ const byUrl = new Map(tombstones.map((t) => [t.url, t.removedAt]));
39066
+ return items.filter((item) => {
39067
+ const removedAt = byUrl.get(urlOf(item));
39068
+ if (removedAt === void 0)
39069
+ return true;
39070
+ return touchedAtOf(item) > removedAt;
39071
+ });
39072
+ }
38703
39073
  function mergeStates(local, remote) {
39074
+ const tombstones = mergeTombstones(local.tombstones ?? [], remote.tombstones ?? []);
39075
+ const savedResults = applyTombstones(mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []), tombstones, (r) => r.issueUrl, (r) => r.lastSeenAt);
39076
+ const skippedIssues = applyTombstones(mergeSkippedIssues(local.skippedIssues ?? [], remote.skippedIssues ?? []), tombstones, (s) => s.url, (s) => s.skippedAt);
39077
+ const skippedUrls = new Set(skippedIssues.map((s) => s.url));
39078
+ const savedResultsFinal = savedResults.filter((r) => !skippedUrls.has(r.issueUrl));
39079
+ const localPrefsTs = local.preferencesUpdatedAt;
39080
+ const remotePrefsTs = remote.preferencesUpdatedAt;
39081
+ const localPrefsWin = localPrefsTs !== void 0 && (remotePrefsTs === void 0 || localPrefsTs > remotePrefsTs);
39082
+ const preferences = localPrefsWin ? local.preferences : remote.preferences;
39083
+ const preferencesUpdatedAt = pickFresherTimestamp(localPrefsTs, remotePrefsTs);
38704
39084
  return {
39085
+ ...local,
39086
+ ...remote,
38705
39087
  version: 1,
38706
- preferences: remote.preferences,
39088
+ preferences,
39089
+ preferencesUpdatedAt,
39090
+ tombstones,
38707
39091
  repoScores: mergeRepoScores(local.repoScores, remote.repoScores),
38708
39092
  starredRepos: mergeStarredRepos(local, remote),
38709
39093
  starredReposLastFetched: pickFresherTimestamp(local.starredReposLastFetched, remote.starredReposLastFetched),
38710
39094
  mergedPRs: unionByUrl(local.mergedPRs, remote.mergedPRs),
38711
39095
  closedPRs: unionByUrl(local.closedPRs, remote.closedPRs),
38712
39096
  openPRs: unionByUrl(local.openPRs ?? [], remote.openPRs ?? []),
38713
- savedResults: mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []),
38714
- skippedIssues: mergeSkippedIssues(local.skippedIssues ?? [], remote.skippedIssues ?? []),
39097
+ savedResults: savedResultsFinal,
39098
+ skippedIssues,
38715
39099
  lastSearchAt: pickFresherTimestamp(local.lastSearchAt, remote.lastSearchAt),
38716
39100
  lastRunAt: pickFresherTimestamp(local.lastRunAt, remote.lastRunAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
38717
39101
  gistId: remote.gistId ?? local.gistId
@@ -38793,7 +39177,7 @@ function loadLocalState() {
38793
39177
  const statePath = getStatePath();
38794
39178
  try {
38795
39179
  const raw = fs4.readFileSync(statePath, "utf-8");
38796
- return ScoutStateSchema.parse(JSON.parse(raw));
39180
+ return parseScoutState(JSON.parse(raw));
38797
39181
  } catch (err) {
38798
39182
  const code = err?.code;
38799
39183
  if (code === "ENOENT") {
@@ -38810,6 +39194,23 @@ function loadLocalState() {
38810
39194
  return ScoutStateSchema.parse({ version: 1 });
38811
39195
  }
38812
39196
  }
39197
+ var tmpCounter = 0;
39198
+ function saveLocalState(state) {
39199
+ const statePath = getStatePath();
39200
+ const tmpPath = `${statePath}.tmp.${process.pid}.${tmpCounter++}`;
39201
+ const data = JSON.stringify(state, null, 2) + "\n";
39202
+ fs4.writeFileSync(tmpPath, data, { mode: 384 });
39203
+ try {
39204
+ fs4.renameSync(tmpPath, statePath);
39205
+ } catch (err) {
39206
+ try {
39207
+ fs4.unlinkSync(tmpPath);
39208
+ } catch {
39209
+ }
39210
+ throw err;
39211
+ }
39212
+ debug(MODULE14, "State saved");
39213
+ }
38813
39214
 
38814
39215
  // ../core/dist/scout.js
38815
39216
  function offlineModeMessage(reason) {
@@ -38864,8 +39265,12 @@ function toGistOctokit(octokit) {
38864
39265
  };
38865
39266
  }
38866
39267
  async function createScout(config2) {
39268
+ if (config2.logLevel !== void 0) {
39269
+ setLogLevel(config2.logLevel);
39270
+ }
38867
39271
  let state;
38868
39272
  let gistStore = null;
39273
+ let persistLocal = false;
38869
39274
  if (config2.persistence === "provided") {
38870
39275
  state = config2.initialState;
38871
39276
  } else if (config2.persistence === "gist") {
@@ -38882,37 +39287,67 @@ async function createScout(config2) {
38882
39287
  state.gistId = result.gistId;
38883
39288
  }
38884
39289
  } else {
38885
- state = ScoutStateSchema.parse({ version: 1 });
39290
+ state = loadLocalState();
39291
+ persistLocal = true;
38886
39292
  }
38887
- return new OssScout(config2.githubToken, state, gistStore);
39293
+ return new OssScout(config2.githubToken, state, gistStore, { persistLocal });
38888
39294
  }
38889
39295
  var OssScout = class {
38890
39296
  githubToken;
38891
39297
  gistStore;
38892
39298
  state;
38893
39299
  dirty = false;
38894
- constructor(githubToken, initialState, gistStore = null) {
39300
+ /** When true, checkpoint() also writes ~/.oss-scout/state.json. */
39301
+ persistLocal;
39302
+ constructor(githubToken, initialState, gistStore = null, opts = {}) {
38895
39303
  this.githubToken = githubToken;
38896
39304
  this.gistStore = gistStore;
38897
39305
  this.state = initialState;
39306
+ this.persistLocal = opts.persistLocal ?? false;
38898
39307
  }
38899
39308
  // ── Search ──────────────────────────────────────────────────────────
39309
+ /**
39310
+ * Drop stale disk-cache entries. Called at the top of every cache-burning
39311
+ * entry point (search, features, vetList); without it ~/.oss-scout/cache
39312
+ * grows without bound. evictStale never throws (fs errors degrade to warn).
39313
+ */
39314
+ evictStaleCacheEntries() {
39315
+ getHttpCache().evictStale();
39316
+ }
38900
39317
  /**
38901
39318
  * Multi-strategy issue search. Returns scored, sorted candidates.
38902
39319
  * Automatically culls expired skip entries and filters skipped issues.
38903
39320
  */
38904
39321
  async search(options) {
39322
+ this.evictStaleCacheEntries();
38905
39323
  this.cullExpiredSkips();
38906
39324
  const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
38907
39325
  const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
39326
+ const prefs = this.state.preferences;
39327
+ const prefLangs = prefs.preferLanguages ?? [];
39328
+ const prefRepos = prefs.preferRepos ?? [];
39329
+ const preferLanguages = options?.preferLanguages ?? (prefLangs.length > 0 ? prefLangs : void 0);
39330
+ const preferRepos = options?.preferRepos ?? (prefRepos.length > 0 ? prefRepos : void 0);
39331
+ const prefAvoid = prefs.avoidRepos ?? [];
39332
+ const prefBoostTypes = prefs.boostIssueTypes ?? [];
39333
+ const avoidRepos = options?.avoidRepos ?? (prefAvoid.length > 0 ? prefAvoid : void 0);
39334
+ const boostIssueTypes = options?.boostIssueTypes ?? (prefBoostTypes.length > 0 ? prefBoostTypes : void 0);
39335
+ const diversityRatio = options?.diversityRatio ?? prefs.diversityRatio ?? 0;
38908
39336
  const { candidates, strategiesUsed } = await discovery.searchIssues({
38909
39337
  maxResults: options?.maxResults,
38910
39338
  strategies: options?.strategies,
38911
39339
  skippedUrls,
38912
- preferLanguages: options?.preferLanguages,
38913
- preferRepos: options?.preferRepos,
38914
- diversityRatio: options?.diversityRatio
39340
+ preferLanguages,
39341
+ preferRepos,
39342
+ avoidRepos,
39343
+ boostIssueTypes,
39344
+ diversityRatio,
39345
+ interPhaseDelayMs: options?.interPhaseDelayMs,
39346
+ broadPhaseDelayMs: options?.broadPhaseDelayMs
38915
39347
  });
39348
+ for (const c of candidates) {
39349
+ this.updateRepoSignalsFromHealth(c.projectHealth);
39350
+ }
38916
39351
  this.state.lastSearchAt = (/* @__PURE__ */ new Date()).toISOString();
38917
39352
  this.dirty = true;
38918
39353
  return {
@@ -38923,6 +39358,27 @@ var OssScout = class {
38923
39358
  strategiesUsed
38924
39359
  };
38925
39360
  }
39361
+ /**
39362
+ * Populate the `hasActiveMaintainers` repo-score signal from a freshly
39363
+ * computed projectHealth (#167). It was initialized false and never set, so
39364
+ * calculateScore's +1 active-maintainers weight was inert; `isActive`
39365
+ * (recent commit activity) is a real, already-computed proxy.
39366
+ *
39367
+ * `isResponsive` and `avgResponseDays` are deliberately NOT set here, and
39368
+ * `isResponsive` no longer carries a score weight at all (#167): real
39369
+ * responsiveness needs an actual response-time measurement (extra API calls)
39370
+ * that is out of scope, and `hasActiveMaintainers` already covers the
39371
+ * activity proxy. `hasHostileComments` stays a host-settable capability (it
39372
+ * needs comment sentiment, out of scope). A failed health check is skipped so
39373
+ * its neutral-default fields don't pollute the score.
39374
+ */
39375
+ updateRepoSignalsFromHealth(health) {
39376
+ if (health.checkFailed)
39377
+ return;
39378
+ this.updateRepoScore(health.repo, {
39379
+ signals: { hasActiveMaintainers: health.isActive }
39380
+ });
39381
+ }
38926
39382
  /**
38927
39383
  * Vet a single issue URL for claimability.
38928
39384
  */
@@ -38945,6 +39401,7 @@ var OssScout = class {
38945
39401
  * preferences and excluded repos/orgs.
38946
39402
  */
38947
39403
  async features(options) {
39404
+ this.evictStaleCacheEntries();
38948
39405
  const count = options?.count ?? 10;
38949
39406
  const octokit = getOctokit(this.githubToken);
38950
39407
  const vetter = new IssueVetter(octokit, this);
@@ -38976,6 +39433,7 @@ var OssScout = class {
38976
39433
  * Optionally prunes unavailable issues from saved results.
38977
39434
  */
38978
39435
  async vetList(options) {
39436
+ this.evictStaleCacheEntries();
38979
39437
  const saved = this.getSavedResults();
38980
39438
  const concurrency = options?.concurrency ?? 5;
38981
39439
  const results = [];
@@ -38991,6 +39449,7 @@ var OssScout = class {
38991
39449
  number: item.number,
38992
39450
  title: item.title,
38993
39451
  status: this.classifyVetResult(candidate),
39452
+ ok: true,
38994
39453
  recommendation: candidate.recommendation,
38995
39454
  viabilityScore: candidate.viabilityScore
38996
39455
  });
@@ -39007,6 +39466,7 @@ var OssScout = class {
39007
39466
  number: item.number,
39008
39467
  title: item.title,
39009
39468
  status: isGone ? "closed" : "error",
39469
+ ok: false,
39010
39470
  errorMessage: errorMessage(error48)
39011
39471
  });
39012
39472
  }).finally(() => {
@@ -39032,17 +39492,41 @@ var OssScout = class {
39032
39492
  hasPR: results.filter((r) => r.status === "has_pr").length,
39033
39493
  errors: results.filter((r) => r.status === "error").length
39034
39494
  };
39495
+ const prevStatus = new Map((this.state.savedResults ?? []).map((r) => [r.issueUrl, r.lastStatus]));
39496
+ const transitions = results.filter((r) => r.status !== "error").filter((r) => {
39497
+ const prev = prevStatus.get(r.issueUrl);
39498
+ return prev !== void 0 && prev !== r.status;
39499
+ }).map((r) => ({
39500
+ issueUrl: r.issueUrl,
39501
+ repo: r.repo,
39502
+ number: r.number,
39503
+ from: prevStatus.get(r.issueUrl),
39504
+ to: r.status
39505
+ }));
39506
+ const currentStatus = new Map(results.map((r) => [r.issueUrl, r.status]));
39507
+ for (const saved2 of this.state.savedResults ?? []) {
39508
+ const status = currentStatus.get(saved2.issueUrl);
39509
+ if (status !== void 0 && status !== "error") {
39510
+ saved2.lastStatus = status;
39511
+ }
39512
+ }
39513
+ if (results.length > 0)
39514
+ this.dirty = true;
39035
39515
  let prunedCount;
39036
39516
  if (options?.prune) {
39037
39517
  const unavailableUrls = new Set(results.filter((r) => r.status !== "still_available").map((r) => r.issueUrl));
39038
39518
  const before = (this.state.savedResults ?? []).length;
39039
39519
  this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
39040
39520
  prunedCount = before - (this.state.savedResults?.length ?? 0);
39521
+ if (prunedCount > 0)
39522
+ this.addTombstones([...unavailableUrls]);
39041
39523
  this.dirty = true;
39042
39524
  }
39043
- return { results, summary, prunedCount };
39525
+ return { results, summary, prunedCount, transitions };
39044
39526
  }
39045
39527
  classifyVetResult(candidate) {
39528
+ if (candidate.issueState === "closed")
39529
+ return "closed";
39046
39530
  const checks = candidate.vettingResult.checks;
39047
39531
  if (!checks.noExistingPR)
39048
39532
  return "has_pr";
@@ -39077,19 +39561,35 @@ var OssScout = class {
39077
39561
  getProjectCategories() {
39078
39562
  return this.state.preferences.projectCategories;
39079
39563
  }
39564
+ /** Configured GitHub username (used to classify own vs competing PRs, #166). */
39565
+ getGitHubUsername() {
39566
+ return this.state.preferences.githubUsername;
39567
+ }
39080
39568
  getRepoScore(repo) {
39081
39569
  const score = this.state.repoScores[repo];
39082
39570
  return score ? score.score : null;
39083
39571
  }
39084
39572
  /**
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.
39573
+ * Number of the user's PRs closed without merge in this repo (#125).
39574
+ * Prefers the tracked repo score; falls back to counting closedPRs so the
39575
+ * scoring penalty works even before a score record exists.
39576
+ */
39577
+ getClosedWithoutMergeCount(repo) {
39578
+ const score = this.state.repoScores[repo];
39579
+ if (score)
39580
+ return score.closedWithoutMergeCount;
39581
+ return (this.state.closedPRs ?? []).filter((p) => extractRepoFromUrl(p.url) === repo).length;
39582
+ }
39583
+ /**
39584
+ * SLM pre-triage config read from preferences (oss-autopilot#1122). Returns
39585
+ * `null` when no `slmTriageModel` is configured — the vetter skips the SLM
39586
+ * call entirely (#158).
39087
39587
  */
39088
39588
  getSLMTriageConfig() {
39089
- return {
39090
- model: this.state.preferences.slmTriageModel ?? "",
39091
- host: this.state.preferences.slmTriageHost ?? ""
39092
- };
39589
+ const model = this.state.preferences.slmTriageModel ?? "";
39590
+ if (!model)
39591
+ return null;
39592
+ return { model, host: this.state.preferences.slmTriageHost ?? "" };
39093
39593
  }
39094
39594
  /** Get current preferences (read-only). */
39095
39595
  getPreferences() {
@@ -39143,6 +39643,73 @@ var OssScout = class {
39143
39643
  ];
39144
39644
  this.dirty = true;
39145
39645
  }
39646
+ /**
39647
+ * Reconcile tracked open PRs against their current GitHub state (#164).
39648
+ *
39649
+ * `state.openPRs` was append-only — nothing transitioned an open PR to
39650
+ * merged/closed, so getReposWithOpenPRs() over-reported forever once a PR
39651
+ * merged. This checks each open PR, records merges/closures (which updates
39652
+ * the repo score), prunes resolved entries, and checkpoints. Cheaper than a
39653
+ * full bootstrap, so a host can call it on daily startup. Transient errors
39654
+ * leave the entry in place; auth/rate-limit failures propagate.
39655
+ */
39656
+ async syncOpenPRs() {
39657
+ const octokit = getOctokit(this.githubToken);
39658
+ const open = this.state.openPRs ?? [];
39659
+ const result = {
39660
+ checked: open.length,
39661
+ merged: 0,
39662
+ closed: 0,
39663
+ stillOpen: 0,
39664
+ errors: 0
39665
+ };
39666
+ const remaining = [];
39667
+ for (const pr of open) {
39668
+ const parsed = parseGitHubUrl(pr.url);
39669
+ if (!parsed || parsed.type !== "pull") {
39670
+ remaining.push(pr);
39671
+ result.errors++;
39672
+ continue;
39673
+ }
39674
+ const repoFullName = `${parsed.owner}/${parsed.repo}`;
39675
+ try {
39676
+ const { data } = await octokit.pulls.get({
39677
+ owner: parsed.owner,
39678
+ repo: parsed.repo,
39679
+ pull_number: parsed.number
39680
+ });
39681
+ if (data.merged) {
39682
+ this.recordMergedPR({
39683
+ url: pr.url,
39684
+ title: pr.title,
39685
+ mergedAt: data.merged_at ?? (/* @__PURE__ */ new Date()).toISOString(),
39686
+ repo: repoFullName
39687
+ });
39688
+ result.merged++;
39689
+ } else if (data.state === "closed") {
39690
+ this.recordClosedPR({
39691
+ url: pr.url,
39692
+ title: pr.title,
39693
+ closedAt: data.closed_at ?? (/* @__PURE__ */ new Date()).toISOString(),
39694
+ repo: repoFullName
39695
+ });
39696
+ result.closed++;
39697
+ } else {
39698
+ remaining.push(pr);
39699
+ result.stillOpen++;
39700
+ }
39701
+ } catch (err) {
39702
+ rethrowIfFatal(err);
39703
+ warn("scout", `sync: could not check ${pr.url}: ${errorMessage(err)}`);
39704
+ remaining.push(pr);
39705
+ result.errors++;
39706
+ }
39707
+ }
39708
+ this.state.openPRs = remaining;
39709
+ this.dirty = true;
39710
+ await this.checkpoint();
39711
+ return result;
39712
+ }
39146
39713
  /**
39147
39714
  * Update repo score with observed signals.
39148
39715
  */
@@ -39177,6 +39744,7 @@ var OssScout = class {
39177
39744
  */
39178
39745
  updatePreferences(updates) {
39179
39746
  this.state.preferences = { ...this.state.preferences, ...updates };
39747
+ this.state.preferencesUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
39180
39748
  this.dirty = true;
39181
39749
  }
39182
39750
  /**
@@ -39226,9 +39794,25 @@ var OssScout = class {
39226
39794
  * Clear all saved results.
39227
39795
  */
39228
39796
  clearResults() {
39797
+ this.addTombstones((this.state.savedResults ?? []).map((r) => r.issueUrl));
39229
39798
  this.state.savedResults = [];
39230
39799
  this.dirty = true;
39231
39800
  }
39801
+ /**
39802
+ * Record deletion tombstones (#117) so a later gist merge does not
39803
+ * resurrect these URLs from another machine's copy. A re-add with a newer
39804
+ * timestamp overrides the tombstone in mergeStates.
39805
+ */
39806
+ addTombstones(urls) {
39807
+ if (urls.length === 0)
39808
+ return;
39809
+ const removedAt = (/* @__PURE__ */ new Date()).toISOString();
39810
+ const existing = this.state.tombstones ?? [];
39811
+ const byUrl = new Map(existing.map((t) => [t.url, t]));
39812
+ for (const url2 of urls)
39813
+ byUrl.set(url2, { url: url2, removedAt });
39814
+ this.state.tombstones = [...byUrl.values()];
39815
+ }
39232
39816
  // ── Skip List ───────────────────────────────────────────────────────
39233
39817
  /**
39234
39818
  * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
@@ -39262,13 +39846,17 @@ var OssScout = class {
39262
39846
  * Remove a specific issue from the skip list.
39263
39847
  */
39264
39848
  unskipIssue(url2) {
39849
+ const before = (this.state.skippedIssues ?? []).length;
39265
39850
  this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url2);
39851
+ if (this.state.skippedIssues.length < before)
39852
+ this.addTombstones([url2]);
39266
39853
  this.dirty = true;
39267
39854
  }
39268
39855
  /**
39269
39856
  * Clear all skipped issues.
39270
39857
  */
39271
39858
  clearSkippedIssues() {
39859
+ this.addTombstones((this.state.skippedIssues ?? []).map((s) => s.url));
39272
39860
  this.state.skippedIssues = [];
39273
39861
  this.dirty = true;
39274
39862
  }
@@ -39312,6 +39900,13 @@ var OssScout = class {
39312
39900
  if (!ok)
39313
39901
  return false;
39314
39902
  }
39903
+ if (this.persistLocal) {
39904
+ try {
39905
+ saveLocalState(this.state);
39906
+ } catch {
39907
+ return false;
39908
+ }
39909
+ }
39315
39910
  this.dirty = false;
39316
39911
  return true;
39317
39912
  }
@@ -39332,16 +39927,21 @@ var OssScout = class {
39332
39927
  });
39333
39928
  }
39334
39929
  /**
39335
- * Calculate repo score (1-10) from observed data.
39930
+ * Calculate repo score from observed data.
39336
39931
  * base 5, +1 per merged PR (max +3), -1 per closed-without-merge (max -3),
39337
- * +1 responsive, +1 active maintainers, -2 hostile comments, clamped 1-10
39932
+ * +1 active maintainers, -2 hostile comments, clamped to [1, 10].
39933
+ *
39934
+ * `isResponsive` is intentionally NOT scored (#167): nothing in oss-scout
39935
+ * ever computes it, so awarding +1 for it was dead weight that the
39936
+ * now-computed `hasActiveMaintainers` signal already covers as the activity
39937
+ * proxy. The field is retained on RepoSignals for backward compatibility but
39938
+ * no longer affects the score. With the current signals the reachable range
39939
+ * is [1, 9]; the upper clamp stays as defensive hygiene.
39338
39940
  */
39339
39941
  calculateScore(repoScore) {
39340
39942
  let score = 5;
39341
39943
  score += Math.min(repoScore.mergedPRCount, 3);
39342
39944
  score -= Math.min(repoScore.closedWithoutMergeCount, 3);
39343
- if (repoScore.signals.isResponsive)
39344
- score += 1;
39345
39945
  if (repoScore.signals.hasActiveMaintainers)
39346
39946
  score += 1;
39347
39947
  if (repoScore.signals.hasHostileComments)
@@ -39350,8 +39950,158 @@ var OssScout = class {
39350
39950
  }
39351
39951
  };
39352
39952
 
39953
+ // ../core/dist/core/preference-fields.js
39954
+ var FIELD_CONFIGS = {
39955
+ githubUsername: { type: "string" },
39956
+ languages: { type: "array" },
39957
+ labels: { type: "array" },
39958
+ scope: { type: "enum-array", validValues: IssueScopeSchema.options },
39959
+ excludeRepos: { type: "array" },
39960
+ excludeOrgs: { type: "array" },
39961
+ aiPolicyBlocklist: { type: "array" },
39962
+ projectCategories: {
39963
+ type: "enum-array",
39964
+ validValues: ProjectCategorySchema.options
39965
+ },
39966
+ minStars: { type: "number" },
39967
+ maxIssueAgeDays: { type: "number" },
39968
+ includeDocIssues: { type: "boolean" },
39969
+ minRepoScoreThreshold: { type: "number" },
39970
+ interPhaseDelayMs: { type: "number" },
39971
+ persistence: { type: "enum", validValues: PersistenceModeSchema.options },
39972
+ defaultStrategy: {
39973
+ type: "enum-array",
39974
+ validValues: SearchStrategySchema.options
39975
+ },
39976
+ broadPhaseDelayMs: { type: "number" },
39977
+ skipBroadWhenSufficientResults: { type: "number" },
39978
+ preferLanguages: { type: "array" },
39979
+ preferRepos: { type: "array" },
39980
+ diversityRatio: { type: "float" },
39981
+ avoidRepos: { type: "array" },
39982
+ boostIssueTypes: { type: "array" },
39983
+ slmTriageModel: { type: "string" },
39984
+ slmTriageHost: { type: "string" },
39985
+ featuresAnchorThreshold: { type: "number" },
39986
+ featuresSplitRatio: { type: "float" }
39987
+ };
39988
+ var PREFERENCE_KEYS = Object.keys(ScoutPreferencesSchema.shape);
39989
+ var SORTED_PREFERENCE_KEYS = [
39990
+ ...PREFERENCE_KEYS
39991
+ ].sort();
39992
+ function parseBoolean(value) {
39993
+ const lower = value.toLowerCase();
39994
+ if (lower === "true" || lower === "yes")
39995
+ return true;
39996
+ if (lower === "false" || lower === "no")
39997
+ return false;
39998
+ throw new ValidationError(`Invalid boolean value: "${value}". Use true/false or yes/no.`);
39999
+ }
40000
+ function parseIntValue(value, key) {
40001
+ const num = parseInt(value, 10);
40002
+ if (isNaN(num)) {
40003
+ throw new ValidationError(`Invalid number for "${key}": "${value}"`);
40004
+ }
40005
+ return num;
40006
+ }
40007
+ function parseFloatValue(value, key) {
40008
+ const num = Number.parseFloat(value);
40009
+ if (isNaN(num)) {
40010
+ throw new ValidationError(`Invalid number for "${key}": "${value}"`);
40011
+ }
40012
+ return num;
40013
+ }
40014
+ function parseArrayValue(value) {
40015
+ return value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
40016
+ }
40017
+ function updateArray(current, value) {
40018
+ if (value.startsWith("+")) {
40019
+ const toAdd = parseArrayValue(value.slice(1));
40020
+ const merged = [...current];
40021
+ for (const item of toAdd) {
40022
+ if (!merged.includes(item))
40023
+ merged.push(item);
40024
+ }
40025
+ return merged;
40026
+ }
40027
+ if (value.startsWith("-")) {
40028
+ const toRemove = new Set(parseArrayValue(value.slice(1)));
40029
+ return current.filter((item) => !toRemove.has(item));
40030
+ }
40031
+ return parseArrayValue(value);
40032
+ }
40033
+ function applyPreferenceField(preferences, key, value) {
40034
+ const field = FIELD_CONFIGS[key];
40035
+ if (!field) {
40036
+ throw new ValidationError(`Unknown config key: "${key}". Valid keys: ${SORTED_PREFERENCE_KEYS.join(", ")}`);
40037
+ }
40038
+ const prefs = { ...preferences };
40039
+ switch (field.type) {
40040
+ case "string":
40041
+ prefs[key] = value;
40042
+ break;
40043
+ case "boolean":
40044
+ prefs[key] = parseBoolean(value);
40045
+ break;
40046
+ case "number":
40047
+ prefs[key] = parseIntValue(value, key);
40048
+ break;
40049
+ case "float":
40050
+ prefs[key] = parseFloatValue(value, key);
40051
+ break;
40052
+ case "array": {
40053
+ const current = prefs[key] ?? [];
40054
+ prefs[key] = updateArray(current, value);
40055
+ break;
40056
+ }
40057
+ case "enum": {
40058
+ const validValues = field.validValues;
40059
+ if (!validValues.includes(value)) {
40060
+ throw new ValidationError(`Invalid value for "${key}": "${value}". Valid: ${validValues.join(", ")}`);
40061
+ }
40062
+ prefs[key] = value;
40063
+ break;
40064
+ }
40065
+ case "enum-array": {
40066
+ const current = prefs[key] ?? [];
40067
+ const updated = updateArray(current, value);
40068
+ const validValues = field.validValues;
40069
+ const invalid = updated.filter((s) => !validValues.includes(s));
40070
+ if (invalid.length > 0) {
40071
+ throw new ValidationError(`Invalid value(s) for "${key}": ${invalid.join(", ")}. Valid: ${validValues.join(", ")}`);
40072
+ }
40073
+ if (key === "scope") {
40074
+ prefs[key] = updated.length > 0 ? updated : void 0;
40075
+ } else {
40076
+ prefs[key] = updated;
40077
+ }
40078
+ break;
40079
+ }
40080
+ }
40081
+ return ScoutPreferencesSchema.parse(prefs);
40082
+ }
40083
+
40084
+ // ../core/dist/commands/validation.js
40085
+ var ISSUE_URL_PATTERN = /^https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+$/;
40086
+ var MAX_URL_LENGTH = 2048;
40087
+ function validateGitHubUrl(url2, pattern, entityType) {
40088
+ if (pattern.test(url2))
40089
+ return;
40090
+ throw new ValidationError(`Invalid ${entityType} URL: ${url2}. Expected format: https://github.com/owner/repo/issues/123`);
40091
+ }
40092
+ function validateUrl(url2) {
40093
+ if (url2.length > MAX_URL_LENGTH) {
40094
+ throw new ValidationError(`URL exceeds maximum length of ${MAX_URL_LENGTH} characters`);
40095
+ }
40096
+ return url2;
40097
+ }
40098
+
39353
40099
  // src/tools.ts
39354
- var TOOL_TIMEOUT_MS = 6e4;
40100
+ var TOOL_TIMEOUT_MS = 3e5;
40101
+ var PERSIST_WARNING = "Checkpoint failed: changes were applied in memory but could not be persisted. They may be lost when the server restarts.";
40102
+ function withPersistWarning(payload, persisted) {
40103
+ return persisted ? payload : { ...payload, persistWarning: PERSIST_WARNING };
40104
+ }
39355
40105
  function withTimeout(promise2, ms = TOOL_TIMEOUT_MS) {
39356
40106
  let timeoutId;
39357
40107
  const timer = new Promise((_, reject) => {
@@ -39377,6 +40127,12 @@ function registerTools(server, scout) {
39377
40127
  preferRepos: external_exports3.array(external_exports3.string()).optional().describe(
39378
40128
  "Soft-boost ranking for candidates in one of these `owner/repo` slugs. Stronger weight than language match (#1244). Not a filter; only reorders."
39379
40129
  ),
40130
+ avoidRepos: external_exports3.array(external_exports3.string()).optional().describe(
40131
+ "Soft PENALTY (milder than excludeRepos) for candidates in one of these `owner/repo` slugs (#168). Pushes them down the ranking but does not remove them; a strong boost can still outweigh it."
40132
+ ),
40133
+ boostIssueTypes: external_exports3.array(external_exports3.string()).optional().describe(
40134
+ 'Soft-boost ranking for candidates whose issue labels match one of these types, case-insensitive (e.g. "bug", "good first issue") (#168). Not a filter; only reorders.'
40135
+ ),
39380
40136
  diversityRatio: external_exports3.number().min(0).max(1).optional().describe(
39381
40137
  "Diversity counterweight (#1244): fraction of result slots reserved for candidates that matched NEITHER preference list. Counters echo-chamber bias when boosts accumulate over time. 0 disables, 1 reserves every slot for diversity. Default 0."
39382
40138
  )
@@ -39386,6 +40142,8 @@ function registerTools(server, scout) {
39386
40142
  strategies,
39387
40143
  preferLanguages,
39388
40144
  preferRepos,
40145
+ avoidRepos,
40146
+ boostIssueTypes,
39389
40147
  diversityRatio
39390
40148
  }) => {
39391
40149
  try {
@@ -39396,13 +40154,28 @@ function registerTools(server, scout) {
39396
40154
  strategies: parsedStrategies,
39397
40155
  preferLanguages,
39398
40156
  preferRepos,
39399
- diversityRatio
40157
+ avoidRepos,
40158
+ boostIssueTypes,
40159
+ diversityRatio,
40160
+ // No fixed inter-phase sleeps in the request/response MCP context;
40161
+ // the budget tracker still paces the GitHub calls (#143)
40162
+ interPhaseDelayMs: 0,
40163
+ broadPhaseDelayMs: 0
39400
40164
  })
39401
40165
  );
39402
40166
  scout.saveResults(result.candidates);
39403
- await scout.checkpoint();
40167
+ const persisted = await scout.checkpoint();
39404
40168
  return {
39405
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
40169
+ content: [
40170
+ {
40171
+ type: "text",
40172
+ text: JSON.stringify(
40173
+ withPersistWarning(result, persisted),
40174
+ null,
40175
+ 2
40176
+ )
40177
+ }
40178
+ ]
39406
40179
  };
39407
40180
  } catch (err) {
39408
40181
  const msg = err instanceof Error ? err.message : String(err);
@@ -39431,9 +40204,18 @@ function registerTools(server, scout) {
39431
40204
  })
39432
40205
  );
39433
40206
  scout.saveResults([...result.quickWins, ...result.biggerBets]);
39434
- await scout.checkpoint();
40207
+ const persisted = await scout.checkpoint();
39435
40208
  return {
39436
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
40209
+ content: [
40210
+ {
40211
+ type: "text",
40212
+ text: JSON.stringify(
40213
+ withPersistWarning(result, persisted),
40214
+ null,
40215
+ 2
40216
+ )
40217
+ }
40218
+ ]
39437
40219
  };
39438
40220
  } catch (err) {
39439
40221
  const msg = err instanceof Error ? err.message : String(err);
@@ -39477,6 +40259,24 @@ function registerTools(server, scout) {
39477
40259
  };
39478
40260
  }
39479
40261
  );
40262
+ server.tool(
40263
+ "sync",
40264
+ "Reconcile tracked open PRs against GitHub \u2014 mark merged/closed and recompute repo scores. Cheap; good for a daily startup call.",
40265
+ async () => {
40266
+ try {
40267
+ const result = await withTimeout(scout.syncOpenPRs());
40268
+ return {
40269
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
40270
+ };
40271
+ } catch (err) {
40272
+ const msg = err instanceof Error ? err.message : String(err);
40273
+ return {
40274
+ content: [{ type: "text", text: `Error: ${msg}` }],
40275
+ isError: true
40276
+ };
40277
+ }
40278
+ }
40279
+ );
39480
40280
  server.tool(
39481
40281
  "skip",
39482
40282
  "Skip, unskip, list, or clear skipped issues. Skipped issues are excluded from future searches and auto-expire after 90 days.",
@@ -39507,7 +40307,7 @@ function registerTools(server, scout) {
39507
40307
  const count = scout.getSkippedIssues().length;
39508
40308
  scout.clearSkippedIssues();
39509
40309
  const synced2 = await scout.checkpoint();
39510
- const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
40310
+ const syncNote2 = synced2 ? "" : " (warning: failed to persist to disk)";
39511
40311
  return {
39512
40312
  content: [
39513
40313
  {
@@ -39521,7 +40321,7 @@ function registerTools(server, scout) {
39521
40321
  const wasPresent = scout.getSkippedIssues().some((s) => s.url === issueUrl);
39522
40322
  scout.unskipIssue(issueUrl);
39523
40323
  const synced2 = await scout.checkpoint();
39524
- const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
40324
+ const syncNote2 = synced2 ? "" : " (warning: failed to persist to disk)";
39525
40325
  return {
39526
40326
  content: [
39527
40327
  {
@@ -39531,6 +40331,8 @@ function registerTools(server, scout) {
39531
40331
  ]
39532
40332
  };
39533
40333
  }
40334
+ validateUrl(issueUrl);
40335
+ validateGitHubUrl(issueUrl, ISSUE_URL_PATTERN, "issue");
39534
40336
  const alreadySkipped = scout.getSkippedIssues().some((s) => s.url === issueUrl);
39535
40337
  if (alreadySkipped) {
39536
40338
  return {
@@ -39547,7 +40349,7 @@ function registerTools(server, scout) {
39547
40349
  } : void 0;
39548
40350
  scout.skipIssue(issueUrl, metadata);
39549
40351
  const synced = await scout.checkpoint();
39550
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
40352
+ const syncNote = synced ? "" : " (warning: failed to persist to disk)";
39551
40353
  return {
39552
40354
  content: [{ type: "text", text: `Skipped: ${issueUrl}${syncNote}` }]
39553
40355
  };
@@ -39560,105 +40362,44 @@ function registerTools(server, scout) {
39560
40362
  }
39561
40363
  }
39562
40364
  );
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
40365
  server.tool(
39586
40366
  "config-set",
39587
40367
  "Update an oss-scout configuration preference",
39588
40368
  {
39589
40369
  key: external_exports3.string().describe(
39590
- "Preference key to update (e.g. languages, minStars, excludeRepos)"
40370
+ `Preference key to update. One of: ${SORTED_PREFERENCE_KEYS.join(", ")}`
39591
40371
  ),
39592
40372
  value: external_exports3.string().describe(
39593
- "New value \u2014 comma-separated for arrays, plain string for scalars"
40373
+ '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
40374
  )
39595
40375
  },
39596
40376
  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("; ");
40377
+ let validated;
40378
+ try {
40379
+ validated = applyPreferenceField(scout.getPreferences(), key, value);
40380
+ } catch (err) {
40381
+ const msg = err instanceof Error ? err.message : String(err);
39652
40382
  return {
39653
- content: [{ type: "text", text: `Validation error: ${issues}` }],
40383
+ content: [{ type: "text", text: msg }],
39654
40384
  isError: true
39655
40385
  };
39656
40386
  }
39657
- scout.updatePreferences({ [key]: parsed });
39658
- await scout.checkpoint();
40387
+ scout.updatePreferences({
40388
+ [key]: validated[key]
40389
+ });
40390
+ const persisted = await scout.checkpoint();
39659
40391
  const updated = scout.getPreferences();
39660
40392
  return {
39661
- content: [{ type: "text", text: JSON.stringify(updated, null, 2) }]
40393
+ content: [
40394
+ {
40395
+ type: "text",
40396
+ text: JSON.stringify(
40397
+ withPersistWarning(updated, persisted),
40398
+ null,
40399
+ 2
40400
+ )
40401
+ }
40402
+ ]
39662
40403
  };
39663
40404
  }
39664
40405
  );
@@ -39714,25 +40455,34 @@ function registerResources(server, scout) {
39714
40455
  }
39715
40456
 
39716
40457
  // 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 });
40458
+ var SERVER_INFO = {
40459
+ name: "oss-scout-mcp",
40460
+ version: "0.10.0"
40461
+ // x-release-please-version
40462
+ };
40463
+ function createServer(scout) {
39726
40464
  const server = new McpServer(
39727
- { name: "oss-scout-mcp", version: "0.2.1" },
40465
+ { name: SERVER_INFO.name, version: SERVER_INFO.version },
39728
40466
  { capabilities: { resources: {}, tools: {} } }
39729
40467
  );
39730
40468
  registerTools(server, scout);
39731
40469
  registerResources(server, scout);
39732
- const transport = new StdioServerTransport();
39733
- await server.connect(transport);
40470
+ return server;
40471
+ }
40472
+ async function runServer() {
40473
+ const token = getGitHubToken();
40474
+ if (!token) {
40475
+ throw new Error(
40476
+ "GitHub token required. Set GITHUB_TOKEN or run `gh auth login`."
40477
+ );
40478
+ }
40479
+ const scout = await createScout({ githubToken: token, persistence: "local" });
40480
+ const server = createServer(scout);
40481
+ await server.connect(new StdioServerTransport());
39734
40482
  }
39735
- main().catch((err) => {
40483
+
40484
+ // src/bin.ts
40485
+ runServer().catch((err) => {
39736
40486
  process.stderr.write(
39737
40487
  `oss-scout-mcp fatal: ${err instanceof Error ? err.message : String(err)}
39738
40488
  `