@oss-scout/core 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  * - repo-health.ts — project health, contribution guidelines
8
8
  */
9
9
  import { parseGitHubUrl } from "./utils.js";
10
- import { ValidationError, errorMessage, isRateLimitError } from "./errors.js";
10
+ import { ValidationError, errorMessage, getHttpStatusCode, isRateLimitError, } from "./errors.js";
11
11
  import { debug, warn } from "./logger.js";
12
12
  import { calculateRepoQualityBonus, calculateViabilityScore, } from "./issue-scoring.js";
13
13
  import { repoBelongsToCategory } from "./category-mapping.js";
@@ -31,11 +31,19 @@ export class IssueVetter {
31
31
  /**
32
32
  * Vet a specific issue — runs all checks and computes recommendation + viability score.
33
33
  * Results are cached for 15 minutes to avoid redundant API calls on repeated searches.
34
+ *
35
+ * `opts.featureSignals` are forwarded directly to scoring; the vetter does
36
+ * not derive them from the fetched issue. Cache key includes a digest of
37
+ * the signals so the same URL with different signals doesn't return a
38
+ * stale score.
34
39
  */
35
- async vetIssue(issueUrl) {
40
+ async vetIssue(issueUrl, opts) {
36
41
  // Check vetting cache first — avoids ~6+ API calls per issue
37
42
  const cache = getHttpCache();
38
- const cacheKey = `vet:${issueUrl}`;
43
+ const sigKey = opts?.featureSignals
44
+ ? `: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}`
45
+ : "";
46
+ const cacheKey = `vet:${issueUrl}${sigKey}`;
39
47
  const cached = cache.getIfFresh(cacheKey, VETTING_CACHE_TTL_MS);
40
48
  if (cached &&
41
49
  typeof cached === "object" &&
@@ -213,6 +221,7 @@ export class IssueVetter {
213
221
  orgHasMergedPRs,
214
222
  repoQualityBonus,
215
223
  matchesPreferredCategory: matchesCategory,
224
+ featureSignals: opts?.featureSignals,
216
225
  });
217
226
  const starredRepos = this.stateReader.getStarredRepos();
218
227
  let searchPriority = "normal";
@@ -263,9 +272,16 @@ export class IssueVetter {
263
272
  let failedVettingCount = 0;
264
273
  let rateLimitFailures = 0;
265
274
  let attemptedCount = 0;
275
+ // Capture the first 401 so we can re-throw after in-flight tasks settle.
276
+ // Per-item tolerance is right for transient failures, but a 401 means
277
+ // the token is invalid and no other issue will succeed either —
278
+ // continuing to log per-issue warnings buries the actual problem.
279
+ let firstAuthError = null;
266
280
  for (const url of urls) {
267
281
  if (candidates.length >= maxResults)
268
282
  break;
283
+ if (firstAuthError)
284
+ break; // stop scheduling once auth has failed
269
285
  attemptedCount++;
270
286
  const task = this.vetIssue(url)
271
287
  .then((candidate) => {
@@ -278,6 +294,10 @@ export class IssueVetter {
278
294
  }
279
295
  })
280
296
  .catch((error) => {
297
+ if (getHttpStatusCode(error) === 401) {
298
+ firstAuthError ??= error;
299
+ return;
300
+ }
281
301
  failedVettingCount++;
282
302
  if (isRateLimitError(error)) {
283
303
  rateLimitFailures++;
@@ -293,6 +313,12 @@ export class IssueVetter {
293
313
  }
294
314
  // Wait for remaining
295
315
  await Promise.allSettled(pending.values());
316
+ if (firstAuthError) {
317
+ if (candidates.length > 0) {
318
+ warn(MODULE, `Auth failed mid-batch after ${candidates.length} successful vet(s) — discarding partial results`);
319
+ }
320
+ throw firstAuthError;
321
+ }
296
322
  const allFailed = failedVettingCount === attemptedCount && attemptedCount > 0;
297
323
  if (allFailed) {
298
324
  warn(MODULE, `All ${attemptedCount} issue(s) failed vetting. ` +
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Helpers for reasoning about linked PRs surfaced via the issue timeline.
3
+ *
4
+ * Currently scoped to detecting "stalled" PRs — open PRs that have not been
5
+ * updated in the last `STALLED_PR_THRESHOLD_DAYS` days. Stalled linked PRs
6
+ * are surfaced as revive opportunities (#97). Note: this module does NOT
7
+ * change scoring; the existing -30 penalty on issues with linked PRs is
8
+ * preserved. This is annotation-only.
9
+ */
10
+ import type { LinkedPR } from "./schemas.js";
11
+ /** Days of inactivity that classify a linked PR as "stalled". */
12
+ export declare const STALLED_PR_THRESHOLD_DAYS = 30;
13
+ /**
14
+ * Determine whether a linked PR is stalled (open + not updated in the
15
+ * last STALLED_PR_THRESHOLD_DAYS days). Returns false when the PR is
16
+ * closed/merged, when updatedAt is missing, or when the threshold isn't met.
17
+ */
18
+ export declare function isLinkedPRStalled(linkedPR: LinkedPR | null | undefined, now?: Date, thresholdDays?: number): boolean;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Helpers for reasoning about linked PRs surfaced via the issue timeline.
3
+ *
4
+ * Currently scoped to detecting "stalled" PRs — open PRs that have not been
5
+ * updated in the last `STALLED_PR_THRESHOLD_DAYS` days. Stalled linked PRs
6
+ * are surfaced as revive opportunities (#97). Note: this module does NOT
7
+ * change scoring; the existing -30 penalty on issues with linked PRs is
8
+ * preserved. This is annotation-only.
9
+ */
10
+ /** Days of inactivity that classify a linked PR as "stalled". */
11
+ export const STALLED_PR_THRESHOLD_DAYS = 30;
12
+ /**
13
+ * Determine whether a linked PR is stalled (open + not updated in the
14
+ * last STALLED_PR_THRESHOLD_DAYS days). Returns false when the PR is
15
+ * closed/merged, when updatedAt is missing, or when the threshold isn't met.
16
+ */
17
+ export function isLinkedPRStalled(linkedPR, now = new Date(), thresholdDays = STALLED_PR_THRESHOLD_DAYS) {
18
+ if (!linkedPR || linkedPR.state !== "open" || !linkedPR.updatedAt)
19
+ return false;
20
+ const updated = new Date(linkedPR.updatedAt);
21
+ if (Number.isNaN(updated.getTime()))
22
+ return false;
23
+ const ageDays = (now.getTime() - updated.getTime()) / (1000 * 60 * 60 * 24);
24
+ return ageDays >= thresholdDays;
25
+ }
@@ -73,6 +73,9 @@ export async function checkProjectHealth(octokit, owner, repo) {
73
73
  });
74
74
  }
75
75
  catch (error) {
76
+ if (getHttpStatusCode(error) === 401 || isRateLimitError(error)) {
77
+ throw error;
78
+ }
76
79
  const errMsg = errorMessage(error);
77
80
  warn(MODULE, `Error checking project health for ${owner}/${repo}: ${errMsg}`);
78
81
  return {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Roadmap scraping (#95) — fetches a repo's ROADMAP.md (or variant) and
3
+ * extracts referenced GitHub issue numbers. Used by feature-discovery to
4
+ * apply an `onRoadmap` bonus when the maintainer has publicly committed
5
+ * to an issue in their roadmap doc.
6
+ *
7
+ * Cached for 1 hour per repo. 404s are cached as empty sets so repos
8
+ * without a roadmap don't get re-probed every run.
9
+ *
10
+ * Auth (401) and rate-limit errors propagate, matching the rest of the
11
+ * codebase's error strategy. Other errors degrade gracefully (warn + empty).
12
+ */
13
+ import type { Octokit } from "@octokit/rest";
14
+ /**
15
+ * Parse markdown content for issue number references.
16
+ *
17
+ * Extracts:
18
+ * - bare `#NNN` references that are not on a markdown heading line
19
+ * - `<owner>/<repo>#NNN` cross-repo refs that match the repo we're parsing for
20
+ * - `https://github.com/<owner>/<repo>/issues/NNN` URLs that match the
21
+ * repo we're parsing for (URLs to other repos are ignored)
22
+ *
23
+ * The match for cross-repo `owner/repo#N` and full URLs is intentional:
24
+ * scattered references to unrelated repos in a roadmap shouldn't bump
25
+ * scores in those repos.
26
+ */
27
+ export declare function parseRoadmapIssueRefs(content: string, owner: string, repo: string): Set<number>;
28
+ /**
29
+ * Fetch and parse the repo's ROADMAP.md, returning the set of referenced
30
+ * issue numbers. Returns an empty set if no roadmap is found.
31
+ *
32
+ * Probes ROADMAP_PATHS sequentially until a 200 response is received.
33
+ * Auth/rate-limit errors propagate; other errors are logged and degrade
34
+ * to an empty set.
35
+ */
36
+ export declare function fetchRoadmapIssueRefs(octokit: Octokit, owner: string, repo: string): Promise<Set<number>>;
37
+ /** Test-only: clear the in-memory cache. */
38
+ export declare function _clearRoadmapCacheForTests(): void;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Roadmap scraping (#95) — fetches a repo's ROADMAP.md (or variant) and
3
+ * extracts referenced GitHub issue numbers. Used by feature-discovery to
4
+ * apply an `onRoadmap` bonus when the maintainer has publicly committed
5
+ * to an issue in their roadmap doc.
6
+ *
7
+ * Cached for 1 hour per repo. 404s are cached as empty sets so repos
8
+ * without a roadmap don't get re-probed every run.
9
+ *
10
+ * Auth (401) and rate-limit errors propagate, matching the rest of the
11
+ * codebase's error strategy. Other errors degrade gracefully (warn + empty).
12
+ */
13
+ import { errorMessage, getHttpStatusCode, isRateLimitError } from "./errors.js";
14
+ import { warn } from "./logger.js";
15
+ const MODULE = "roadmap";
16
+ /** TTL for roadmap fetch results (1 hour). */
17
+ const CACHE_TTL_MS = 60 * 60 * 1000;
18
+ /** Paths probed in priority order. First success wins. */
19
+ const ROADMAP_PATHS = [
20
+ "ROADMAP.md",
21
+ "docs/ROADMAP.md",
22
+ ".github/ROADMAP.md",
23
+ "Roadmap.md",
24
+ "roadmap.md",
25
+ ];
26
+ const roadmapCache = new Map();
27
+ /** Drop expired entries. Called from each fetch. */
28
+ function pruneCache() {
29
+ const cutoff = Date.now() - CACHE_TTL_MS;
30
+ for (const [key, value] of roadmapCache.entries()) {
31
+ if (value.fetchedAt < cutoff)
32
+ roadmapCache.delete(key);
33
+ }
34
+ }
35
+ /**
36
+ * Parse markdown content for issue number references.
37
+ *
38
+ * Extracts:
39
+ * - bare `#NNN` references that are not on a markdown heading line
40
+ * - `<owner>/<repo>#NNN` cross-repo refs that match the repo we're parsing for
41
+ * - `https://github.com/<owner>/<repo>/issues/NNN` URLs that match the
42
+ * repo we're parsing for (URLs to other repos are ignored)
43
+ *
44
+ * The match for cross-repo `owner/repo#N` and full URLs is intentional:
45
+ * scattered references to unrelated repos in a roadmap shouldn't bump
46
+ * scores in those repos.
47
+ */
48
+ export function parseRoadmapIssueRefs(content, owner, repo) {
49
+ const refs = new Set();
50
+ const ownerRepoLower = `${owner}/${repo}`.toLowerCase();
51
+ // `owner/repo#N` cross-repo style refs — must match the current repo.
52
+ // We escape regex metachars in case repo names contain `.` or `-`.
53
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
+ const fullRefPattern = new RegExp(`\\b${escape(owner)}/${escape(repo)}#(\\d+)\\b`, "gi");
55
+ for (const m of content.matchAll(fullRefPattern)) {
56
+ const n = Number.parseInt(m[1], 10);
57
+ if (n > 0)
58
+ refs.add(n);
59
+ }
60
+ // Bare `#N` references, scanned line-by-line so we can skip markdown
61
+ // headings (`# title`, `## section`) — those aren't issue refs. We also
62
+ // strip out `owner/repo#N` matches first so `#N` in a cross-repo ref to
63
+ // a different repo doesn't leak in as a bare match.
64
+ const fullRefStripPattern = /\b[\w.-]+\/[\w.-]+#\d+\b/gi;
65
+ for (const line of content.split("\n")) {
66
+ if (/^\s*#+\s/.test(line))
67
+ continue;
68
+ const stripped = line.replace(fullRefStripPattern, "");
69
+ for (const m of stripped.matchAll(/(?:^|[^&\w])#(\d+)\b/g)) {
70
+ const n = Number.parseInt(m[1], 10);
71
+ if (n > 0)
72
+ refs.add(n);
73
+ }
74
+ }
75
+ // Full GitHub issue URLs scoped to this repo.
76
+ const urlPattern = /https?:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/issues\/(\d+)/gi;
77
+ for (const m of content.matchAll(urlPattern)) {
78
+ if (m[1].toLowerCase() === ownerRepoLower) {
79
+ const n = Number.parseInt(m[2], 10);
80
+ if (n > 0)
81
+ refs.add(n);
82
+ }
83
+ }
84
+ return refs;
85
+ }
86
+ /**
87
+ * Fetch and parse the repo's ROADMAP.md, returning the set of referenced
88
+ * issue numbers. Returns an empty set if no roadmap is found.
89
+ *
90
+ * Probes ROADMAP_PATHS sequentially until a 200 response is received.
91
+ * Auth/rate-limit errors propagate; other errors are logged and degrade
92
+ * to an empty set.
93
+ */
94
+ export async function fetchRoadmapIssueRefs(octokit, owner, repo) {
95
+ const cacheKey = `${owner}/${repo}`.toLowerCase();
96
+ const cached = roadmapCache.get(cacheKey);
97
+ if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
98
+ return cached.refs;
99
+ }
100
+ for (const path of ROADMAP_PATHS) {
101
+ try {
102
+ const { data } = await octokit.repos.getContent({ owner, repo, path });
103
+ if (!("content" in data))
104
+ continue;
105
+ const content = Buffer.from(data.content, "base64").toString("utf-8");
106
+ const refs = parseRoadmapIssueRefs(content, owner, repo);
107
+ roadmapCache.set(cacheKey, { refs, fetchedAt: Date.now() });
108
+ pruneCache();
109
+ return refs;
110
+ }
111
+ catch (err) {
112
+ if (getHttpStatusCode(err) === 401 || isRateLimitError(err))
113
+ throw err;
114
+ const status = getHttpStatusCode(err);
115
+ if (status === 404)
116
+ continue; // path missing — try next
117
+ warn(MODULE, `Unexpected error fetching ${path} from ${owner}/${repo}: ${errorMessage(err)}`);
118
+ // Fall through and try next path.
119
+ }
120
+ }
121
+ // No roadmap found (or all probes errored softly). Cache the empty result
122
+ // so we don't re-probe every run.
123
+ const empty = new Set();
124
+ roadmapCache.set(cacheKey, { refs: empty, fetchedAt: Date.now() });
125
+ pruneCache();
126
+ return empty;
127
+ }
128
+ /** Test-only: clear the in-memory cache. */
129
+ export function _clearRoadmapCacheForTests() {
130
+ roadmapCache.clear();
131
+ }
@@ -94,6 +94,7 @@ export declare const LinkedPRSchema: z.ZodObject<{
94
94
  }>;
95
95
  merged: z.ZodBoolean;
96
96
  url: z.ZodString;
97
+ updatedAt: z.ZodOptional<z.ZodString>;
97
98
  }, z.core.$strip>;
98
99
  export declare const IssueVettingResultSchema: z.ZodObject<{
99
100
  passedAllChecks: z.ZodBoolean;
@@ -129,6 +130,7 @@ export declare const IssueVettingResultSchema: z.ZodObject<{
129
130
  }>;
130
131
  merged: z.ZodBoolean;
131
132
  url: z.ZodString;
133
+ updatedAt: z.ZodOptional<z.ZodString>;
132
134
  }, z.core.$strip>>>;
133
135
  notes: z.ZodArray<z.ZodString>;
134
136
  }, z.core.$strip>;
@@ -182,6 +184,7 @@ export declare const TrackedIssueSchema: z.ZodObject<{
182
184
  }>;
183
185
  merged: z.ZodBoolean;
184
186
  url: z.ZodString;
187
+ updatedAt: z.ZodOptional<z.ZodString>;
185
188
  }, z.core.$strip>>>;
186
189
  notes: z.ZodArray<z.ZodString>;
187
190
  }, z.core.$strip>>;
@@ -193,6 +196,10 @@ export declare const SkippedIssueSchema: z.ZodObject<{
193
196
  title: z.ZodString;
194
197
  skippedAt: z.ZodString;
195
198
  }, z.core.$strip>;
199
+ export declare const HorizonSchema: z.ZodEnum<{
200
+ "quick-win": "quick-win";
201
+ "bigger-bet": "bigger-bet";
202
+ }>;
196
203
  export declare const SavedCandidateSchema: z.ZodObject<{
197
204
  issueUrl: z.ZodString;
198
205
  repo: z.ZodString;
@@ -209,6 +216,10 @@ export declare const SavedCandidateSchema: z.ZodObject<{
209
216
  firstSeenAt: z.ZodString;
210
217
  lastSeenAt: z.ZodString;
211
218
  lastScore: z.ZodNumber;
219
+ horizon: z.ZodOptional<z.ZodEnum<{
220
+ "quick-win": "quick-win";
221
+ "bigger-bet": "bigger-bet";
222
+ }>>;
212
223
  }, z.core.$strip>;
213
224
  export declare const PersistenceModeSchema: z.ZodEnum<{
214
225
  local: "local";
@@ -254,6 +265,8 @@ export declare const ScoutPreferencesSchema: z.ZodObject<{
254
265
  skipBroadWhenSufficientResults: z.ZodDefault<z.ZodNumber>;
255
266
  slmTriageModel: z.ZodDefault<z.ZodString>;
256
267
  slmTriageHost: z.ZodDefault<z.ZodString>;
268
+ featuresAnchorThreshold: z.ZodDefault<z.ZodNumber>;
269
+ featuresSplitRatio: z.ZodDefault<z.ZodNumber>;
257
270
  }, z.core.$strip>;
258
271
  export declare const ScoutStateSchema: z.ZodObject<{
259
272
  version: z.ZodLiteral<1>;
@@ -297,6 +310,8 @@ export declare const ScoutStateSchema: z.ZodObject<{
297
310
  skipBroadWhenSufficientResults: z.ZodDefault<z.ZodNumber>;
298
311
  slmTriageModel: z.ZodDefault<z.ZodString>;
299
312
  slmTriageHost: z.ZodDefault<z.ZodString>;
313
+ featuresAnchorThreshold: z.ZodDefault<z.ZodNumber>;
314
+ featuresSplitRatio: z.ZodDefault<z.ZodNumber>;
300
315
  }, z.core.$strip>>;
301
316
  repoScores: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
302
317
  repo: z.ZodString;
@@ -347,6 +362,10 @@ export declare const ScoutStateSchema: z.ZodObject<{
347
362
  firstSeenAt: z.ZodString;
348
363
  lastSeenAt: z.ZodString;
349
364
  lastScore: z.ZodNumber;
365
+ horizon: z.ZodOptional<z.ZodEnum<{
366
+ "quick-win": "quick-win";
367
+ "bigger-bet": "bigger-bet";
368
+ }>>;
350
369
  }, z.core.$strip>>>;
351
370
  skippedIssues: z.ZodDefault<z.ZodArray<z.ZodObject<{
352
371
  url: z.ZodString;
@@ -372,6 +391,7 @@ export type LinkedPR = z.infer<typeof LinkedPRSchema>;
372
391
  export type IssueVettingResult = z.infer<typeof IssueVettingResultSchema>;
373
392
  export type TrackedIssue = z.infer<typeof TrackedIssueSchema>;
374
393
  export type ScoutPreferences = z.infer<typeof ScoutPreferencesSchema>;
394
+ export type Horizon = z.infer<typeof HorizonSchema>;
375
395
  export type SavedCandidate = z.infer<typeof SavedCandidateSchema>;
376
396
  export type SkippedIssue = z.infer<typeof SkippedIssueSchema>;
377
397
  export type ScoutState = z.infer<typeof ScoutStateSchema>;
@@ -95,6 +95,12 @@ export const LinkedPRSchema = z.object({
95
95
  state: z.enum(["open", "closed"]),
96
96
  merged: z.boolean(),
97
97
  url: z.string(),
98
+ /**
99
+ * ISO-8601 timestamp of the linked PR's last update. Optional so existing
100
+ * persisted state validates unchanged. Used by `isLinkedPRStalled` to
101
+ * surface stale revive opportunities (#97).
102
+ */
103
+ updatedAt: z.string().optional(),
98
104
  });
99
105
  export const IssueVettingResultSchema = z.object({
100
106
  passedAllChecks: z.boolean(),
@@ -131,6 +137,7 @@ export const SkippedIssueSchema = z.object({
131
137
  skippedAt: z.string(),
132
138
  });
133
139
  // ── Saved candidate schema ─────────────────────────────────────────
140
+ export const HorizonSchema = z.enum(["quick-win", "bigger-bet"]);
134
141
  export const SavedCandidateSchema = z.object({
135
142
  issueUrl: z.string(),
136
143
  repo: z.string(),
@@ -143,6 +150,7 @@ export const SavedCandidateSchema = z.object({
143
150
  firstSeenAt: z.string(),
144
151
  lastSeenAt: z.string(),
145
152
  lastScore: z.number(),
153
+ horizon: HorizonSchema.optional(),
146
154
  });
147
155
  // ── Scout preferences schema ────────────────────────────────────────
148
156
  export const PersistenceModeSchema = z.enum(["local", "gist"]);
@@ -177,6 +185,18 @@ export const ScoutPreferencesSchema = z.object({
177
185
  * local network.
178
186
  */
179
187
  slmTriageHost: z.string().default(""),
188
+ /**
189
+ * Minimum merged-PR count for a repo to qualify as an anchor in
190
+ * `scout features` (#98). Lowering surfaces more anchors at the cost
191
+ * of weaker prior engagement signal.
192
+ */
193
+ featuresAnchorThreshold: z.number().int().min(1).max(50).default(3),
194
+ /**
195
+ * Quick-wins / bigger-bets split ratio for `scout features` (#99).
196
+ * 0.6 means 60% quick wins, 40% bigger bets when both pools are
197
+ * abundant. Deficits redirect to the other bucket.
198
+ */
199
+ featuresSplitRatio: z.number().min(0).max(1).default(0.6),
180
200
  });
181
201
  // ── Root state schema ───────────────────────────────────────────────
182
202
  export const ScoutStateSchema = z.object({
@@ -370,6 +370,8 @@ export async function searchInRepos(octokit, vetter, repos, baseQualifiers, labe
370
370
  }
371
371
  }
372
372
  catch (error) {
373
+ if (getHttpStatusCode(error) === 401)
374
+ throw error;
373
375
  failedBatches++;
374
376
  if (isRateLimitError(error)) {
375
377
  rateLimitFailures++;
package/dist/index.d.ts CHANGED
@@ -16,9 +16,12 @@
16
16
  */
17
17
  export { createScout, OssScout } from "./scout.js";
18
18
  export type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectHealth, SearchPriority, CheckResult, AntiLLMPolicyResult, AntiLLMPolicySourceFile, VetListOptions, VetListResult, VetListEntry, VetListSummary, } from "./core/types.js";
19
- export type { ScoutState, ScoutPreferences, RepoScore, RepoSignals, IssueVettingResult, LinkedPR, ContributionGuidelines, TrackedIssue, IssueScope, ProjectCategory, StoredMergedPR, StoredClosedPR, StoredOpenPR, SearchStrategy, SkippedIssue, } from "./core/schemas.js";
20
- export { ScoutStateSchema, ScoutPreferencesSchema, RepoScoreSchema, IssueScopeSchema, ProjectCategorySchema, SearchStrategySchema, SkippedIssueSchema, } from "./core/schemas.js";
19
+ export type { ScoutState, ScoutPreferences, RepoScore, RepoSignals, IssueVettingResult, LinkedPR, ContributionGuidelines, TrackedIssue, IssueScope, ProjectCategory, StoredMergedPR, StoredClosedPR, StoredOpenPR, SearchStrategy, SkippedIssue, Horizon, } from "./core/schemas.js";
20
+ export { ScoutStateSchema, ScoutPreferencesSchema, RepoScoreSchema, IssueScopeSchema, ProjectCategorySchema, SearchStrategySchema, SkippedIssueSchema, HorizonSchema, } from "./core/schemas.js";
21
21
  export { requireGitHubToken, getGitHubToken } from "./core/utils.js";
22
22
  export { IssueDiscovery } from "./core/issue-discovery.js";
23
- export { IssueVetter, type ScoutStateReader } from "./core/issue-vetting.js";
23
+ export { IssueVetter, type ScoutStateReader, type FeatureSignals, } from "./core/issue-vetting.js";
24
24
  export { scanForAntiLLMPolicy, ANTI_LLM_KEYWORDS, } from "./core/anti-llm-policy.js";
25
+ export { discoverFeatures, resolveAnchorRepos, classifyHorizon, splitByHorizon, ANCHOR_THRESHOLD, FEATURE_LABELS, NO_ANCHORS_MESSAGE, NO_RESULTS_MESSAGE, type FeatureCandidate, type FeatureSearchResult, type DiscoverFeaturesOptions, } from "./core/feature-discovery.js";
26
+ export { isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, } from "./core/linked-pr.js";
27
+ export { fetchRoadmapIssueRefs, parseRoadmapIssueRefs, } from "./core/roadmap.js";
package/dist/index.js CHANGED
@@ -17,10 +17,16 @@
17
17
  // Main API
18
18
  export { createScout, OssScout } from "./scout.js";
19
19
  // Schemas (for consumers who need runtime validation)
20
- export { ScoutStateSchema, ScoutPreferencesSchema, RepoScoreSchema, IssueScopeSchema, ProjectCategorySchema, SearchStrategySchema, SkippedIssueSchema, } from "./core/schemas.js";
20
+ export { ScoutStateSchema, ScoutPreferencesSchema, RepoScoreSchema, IssueScopeSchema, ProjectCategorySchema, SearchStrategySchema, SkippedIssueSchema, HorizonSchema, } from "./core/schemas.js";
21
21
  // Utilities
22
22
  export { requireGitHubToken, getGitHubToken } from "./core/utils.js";
23
23
  // Internal classes (for advanced use)
24
24
  export { IssueDiscovery } from "./core/issue-discovery.js";
25
- export { IssueVetter } from "./core/issue-vetting.js";
25
+ export { IssueVetter, } from "./core/issue-vetting.js";
26
26
  export { scanForAntiLLMPolicy, ANTI_LLM_KEYWORDS, } from "./core/anti-llm-policy.js";
27
+ // Feature discovery API
28
+ export { discoverFeatures, resolveAnchorRepos, classifyHorizon, splitByHorizon, ANCHOR_THRESHOLD, FEATURE_LABELS, NO_ANCHORS_MESSAGE, NO_RESULTS_MESSAGE, } from "./core/feature-discovery.js";
29
+ // Linked-PR helpers (#97)
30
+ export { isLinkedPRStalled, STALLED_PR_THRESHOLD_DAYS, } from "./core/linked-pr.js";
31
+ // Roadmap scraping (#95)
32
+ export { fetchRoadmapIssueRefs, parseRoadmapIssueRefs, } from "./core/roadmap.js";
package/dist/scout.d.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  * Implements ScoutStateReader to bridge state with the search engine.
6
6
  */
7
7
  import type { ScoutStateReader } from "./core/issue-vetting.js";
8
- import type { ScoutState, ScoutPreferences, RepoScore, SavedCandidate, SkippedIssue } from "./core/schemas.js";
8
+ import { type FeatureSearchResult } from "./core/feature-discovery.js";
9
+ import type { ScoutState, ScoutPreferences, RepoScore, SavedCandidate, SkippedIssue, Horizon } from "./core/schemas.js";
9
10
  import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectCategory, VetListOptions, VetListResult } from "./core/types.js";
10
11
  import { GistStateStore } from "./core/gist-state-store.js";
11
12
  /**
@@ -51,6 +52,26 @@ export declare class OssScout implements ScoutStateReader {
51
52
  * Vet a single issue URL for claimability.
52
53
  */
53
54
  vetIssue(issueUrl: string): Promise<IssueCandidate>;
55
+ /**
56
+ * `scout features` — surfaces feature-scoped contribution opportunities
57
+ * in repos where the user has 3+ merged PRs (configurable via
58
+ * `featuresAnchorThreshold`), ranked into separate "quick wins" and
59
+ * "bigger bets" buckets (split via `featuresSplitRatio`).
60
+ *
61
+ * Per-call `anchorThreshold` and `splitRatio` overrides take precedence
62
+ * over the persisted preferences.
63
+ *
64
+ * When `broad` is true (#100), bypasses anchor resolution and runs a
65
+ * cross-repo GitHub Search query for first-touch contributors who
66
+ * haven't yet built repo relationships. Filters by user language
67
+ * preferences and excluded repos/orgs.
68
+ */
69
+ features(options?: {
70
+ count?: number;
71
+ anchorThreshold?: number;
72
+ splitRatio?: number;
73
+ broad?: boolean;
74
+ }): Promise<FeatureSearchResult>;
54
75
  /**
55
76
  * Re-vet all saved results with bounded concurrency.
56
77
  * Classifies each as still_available, claimed, has_pr, closed, or error.
@@ -106,7 +127,9 @@ export declare class OssScout implements ScoutStateReader {
106
127
  * If a candidate already exists, updates score/recommendation/lastSeenAt
107
128
  * but preserves firstSeenAt.
108
129
  */
109
- saveResults(candidates: IssueCandidate[]): void;
130
+ saveResults(candidates: Array<IssueCandidate | (IssueCandidate & {
131
+ horizon?: Horizon;
132
+ })>): void;
110
133
  /**
111
134
  * Get all saved results.
112
135
  */