@oss-scout/core 0.8.0 → 0.9.1

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.
@@ -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({
@@ -61,6 +61,30 @@ export declare function fetchIssuesFromKnownRepos(octokit: Octokit, vetter: Issu
61
61
  * @param perPage Number of results per API call
62
62
  */
63
63
  export declare function searchWithChunkedLabels(octokit: Octokit, labels: string[], reservedOps: number, buildQuery: (labelQuery: string) => string, perPage: number): Promise<GitHubSearchItem[]>;
64
+ /**
65
+ * Build per-call language qualifier strings, fanning out across languages
66
+ * when a multi-language + labels combination would trip GitHub Search's
67
+ * empty-result edge case (multi-`language:` AND with a label OR-group
68
+ * silently returns 0 — see https://github.com/costajohnt/oss-autopilot/issues/1331).
69
+ */
70
+ export declare function buildLanguageVariants(languages: string[], isAnyLanguage: boolean, hasLabels: boolean): string[];
71
+ /**
72
+ * Search across languages with label chunking, deduplicating results.
73
+ *
74
+ * Fans out one query per language when 2+ languages are paired with labels
75
+ * (works around a GitHub Search backend edge case where the multi-language
76
+ * AND combined with a label OR-group returns 0). For each language variant,
77
+ * delegates to searchWithChunkedLabels to keep within GitHub's 5-operator limit.
78
+ *
79
+ * @param octokit Authenticated Octokit instance
80
+ * @param languages Configured languages (used as `language:X` qualifiers)
81
+ * @param isAnyLanguage When true, skip language qualifiers entirely
82
+ * @param labels Label list passed to searchWithChunkedLabels
83
+ * @param buildBaseQuery Builds the query prefix from a language qualifier string;
84
+ * e.g. `(langQ) => `is:issue is:open ${langQ} no:assignee`.trim()`
85
+ * @param perPage Results per API call
86
+ */
87
+ export declare function searchAcrossLanguagesAndLabels(octokit: Octokit, languages: string[], isAnyLanguage: boolean, labels: string[], buildBaseQuery: (langQuery: string) => string, perPage: number): Promise<GitHubSearchItem[]>;
64
88
  /**
65
89
  * Shared pipeline: spam-filter, repo-exclusion, vetting, and star-count filter.
66
90
  * Used by Phases 2 and 3 to convert raw search results into vetted candidates.
@@ -291,6 +291,56 @@ export async function searchWithChunkedLabels(octokit, labels, reservedOps, buil
291
291
  }
292
292
  return allItems;
293
293
  }
294
+ /**
295
+ * Build per-call language qualifier strings, fanning out across languages
296
+ * when a multi-language + labels combination would trip GitHub Search's
297
+ * empty-result edge case (multi-`language:` AND with a label OR-group
298
+ * silently returns 0 — see https://github.com/costajohnt/oss-autopilot/issues/1331).
299
+ */
300
+ export function buildLanguageVariants(languages, isAnyLanguage, hasLabels) {
301
+ if (isAnyLanguage || languages.length === 0)
302
+ return [""];
303
+ if (languages.length === 1)
304
+ return [`language:${languages[0]}`];
305
+ if (!hasLabels)
306
+ return [languages.map((l) => `language:${l}`).join(" ")];
307
+ return languages.map((l) => `language:${l}`);
308
+ }
309
+ /**
310
+ * Search across languages with label chunking, deduplicating results.
311
+ *
312
+ * Fans out one query per language when 2+ languages are paired with labels
313
+ * (works around a GitHub Search backend edge case where the multi-language
314
+ * AND combined with a label OR-group returns 0). For each language variant,
315
+ * delegates to searchWithChunkedLabels to keep within GitHub's 5-operator limit.
316
+ *
317
+ * @param octokit Authenticated Octokit instance
318
+ * @param languages Configured languages (used as `language:X` qualifiers)
319
+ * @param isAnyLanguage When true, skip language qualifiers entirely
320
+ * @param labels Label list passed to searchWithChunkedLabels
321
+ * @param buildBaseQuery Builds the query prefix from a language qualifier string;
322
+ * e.g. `(langQ) => `is:issue is:open ${langQ} no:assignee`.trim()`
323
+ * @param perPage Results per API call
324
+ */
325
+ export async function searchAcrossLanguagesAndLabels(octokit, languages, isAnyLanguage, labels, buildBaseQuery, perPage) {
326
+ const langVariants = buildLanguageVariants(languages, isAnyLanguage, labels.length > 0);
327
+ const seenUrls = new Set();
328
+ const allItems = [];
329
+ for (let i = 0; i < langVariants.length; i++) {
330
+ if (i > 0)
331
+ await sleep(INTER_QUERY_DELAY_MS);
332
+ const items = await searchWithChunkedLabels(octokit, labels, 0, (labelQ) => `${buildBaseQuery(langVariants[i])} ${labelQ}`
333
+ .replace(/ +/g, " ")
334
+ .trim(), perPage);
335
+ for (const item of items) {
336
+ if (!seenUrls.has(item.html_url)) {
337
+ seenUrls.add(item.html_url);
338
+ allItems.push(item);
339
+ }
340
+ }
341
+ }
342
+ return allItems;
343
+ }
294
344
  /**
295
345
  * Shared pipeline: spam-filter, repo-exclusion, vetting, and star-count filter.
296
346
  * Used by Phases 2 and 3 to convert raw search results into vetted candidates.
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
  */
package/dist/scout.js CHANGED
@@ -5,6 +5,8 @@
5
5
  * Implements ScoutStateReader to bridge state with the search engine.
6
6
  */
7
7
  import { IssueDiscovery } from "./core/issue-discovery.js";
8
+ import { IssueVetter } from "./core/issue-vetting.js";
9
+ import { discoverFeatures, discoverFeaturesBroad, } from "./core/feature-discovery.js";
8
10
  import { ScoutStateSchema } from "./core/schemas.js";
9
11
  import { GistStateStore, mergeStates } from "./core/gist-state-store.js";
10
12
  import { getOctokit } from "./core/github.js";
@@ -164,6 +166,48 @@ export class OssScout {
164
166
  const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
165
167
  return discovery.vetIssue(issueUrl);
166
168
  }
169
+ /**
170
+ * `scout features` — surfaces feature-scoped contribution opportunities
171
+ * in repos where the user has 3+ merged PRs (configurable via
172
+ * `featuresAnchorThreshold`), ranked into separate "quick wins" and
173
+ * "bigger bets" buckets (split via `featuresSplitRatio`).
174
+ *
175
+ * Per-call `anchorThreshold` and `splitRatio` overrides take precedence
176
+ * over the persisted preferences.
177
+ *
178
+ * When `broad` is true (#100), bypasses anchor resolution and runs a
179
+ * cross-repo GitHub Search query for first-touch contributors who
180
+ * haven't yet built repo relationships. Filters by user language
181
+ * preferences and excluded repos/orgs.
182
+ */
183
+ async features(options) {
184
+ const count = options?.count ?? 10;
185
+ const octokit = getOctokit(this.githubToken);
186
+ const vetter = new IssueVetter(octokit, this);
187
+ const result = options?.broad
188
+ ? await discoverFeaturesBroad({
189
+ octokit,
190
+ vetter,
191
+ count,
192
+ languages: this.state.preferences.languages,
193
+ excludeRepos: this.state.preferences.excludeRepos,
194
+ excludeOrgs: this.state.preferences.excludeOrgs,
195
+ splitRatio: options?.splitRatio ?? this.state.preferences.featuresSplitRatio,
196
+ })
197
+ : await discoverFeatures({
198
+ octokit,
199
+ vetter,
200
+ repoScores: this.state.repoScores ?? {},
201
+ count,
202
+ anchorThreshold: options?.anchorThreshold ??
203
+ this.state.preferences.featuresAnchorThreshold,
204
+ splitRatio: options?.splitRatio ?? this.state.preferences.featuresSplitRatio,
205
+ });
206
+ this.saveResults([...result.quickWins, ...result.biggerBets]);
207
+ this.state.lastSearchAt = new Date().toISOString();
208
+ this.dirty = true;
209
+ return result;
210
+ }
167
211
  // ── Batch Vetting ───────────────────────────────────────────────────
168
212
  /**
169
213
  * Re-vet all saved results with bounded concurrency.
@@ -423,6 +467,7 @@ export class OssScout {
423
467
  firstSeenAt: prev?.firstSeenAt ?? now,
424
468
  lastSeenAt: now,
425
469
  lastScore: c.viabilityScore,
470
+ horizon: "horizon" in c ? c.horizon : undefined,
426
471
  });
427
472
  }
428
473
  this.state.savedResults = [...existing.values()];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Personalized GitHub issue finder with multi-strategy search, deep vetting, and viability scoring — CLI, library, MCP server, and Claude Code plugin",
5
5
  "type": "module",
6
6
  "bin": {