@oss-scout/core 0.11.0 → 1.1.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.
Files changed (68) hide show
  1. package/dist/cli.bundle.cjs +89 -66
  2. package/dist/cli.js +302 -436
  3. package/dist/commands/command-scout.d.ts +21 -0
  4. package/dist/commands/command-scout.js +21 -0
  5. package/dist/commands/config.js +10 -128
  6. package/dist/commands/features.js +15 -28
  7. package/dist/commands/results.d.ts +13 -2
  8. package/dist/commands/results.js +29 -2
  9. package/dist/commands/search.d.ts +4 -0
  10. package/dist/commands/search.js +65 -70
  11. package/dist/commands/setup.d.ts +2 -0
  12. package/dist/commands/setup.js +35 -6
  13. package/dist/commands/skip.d.ts +4 -0
  14. package/dist/commands/skip.js +45 -55
  15. package/dist/commands/sync.d.ts +10 -0
  16. package/dist/commands/sync.js +10 -0
  17. package/dist/commands/vet-list.js +3 -19
  18. package/dist/commands/vet.js +18 -25
  19. package/dist/commands/with-scout.d.ts +32 -0
  20. package/dist/commands/with-scout.js +41 -0
  21. package/dist/core/anti-llm-policy.js +5 -33
  22. package/dist/core/bootstrap.d.ts +2 -2
  23. package/dist/core/bootstrap.js +5 -9
  24. package/dist/core/errors.d.ts +10 -0
  25. package/dist/core/errors.js +20 -5
  26. package/dist/core/feature-discovery.d.ts +13 -1
  27. package/dist/core/feature-discovery.js +104 -81
  28. package/dist/core/gist-state-store.d.ts +13 -12
  29. package/dist/core/gist-state-store.js +128 -53
  30. package/dist/core/http-cache.d.ts +32 -2
  31. package/dist/core/http-cache.js +74 -19
  32. package/dist/core/issue-discovery.d.ts +12 -1
  33. package/dist/core/issue-discovery.js +94 -67
  34. package/dist/core/issue-eligibility.d.ts +11 -4
  35. package/dist/core/issue-eligibility.js +124 -69
  36. package/dist/core/issue-graphql.d.ts +58 -0
  37. package/dist/core/issue-graphql.js +108 -0
  38. package/dist/core/issue-vetting.d.ts +115 -9
  39. package/dist/core/issue-vetting.js +246 -109
  40. package/dist/core/local-state.d.ts +6 -2
  41. package/dist/core/local-state.js +23 -5
  42. package/dist/core/logger.d.ts +12 -4
  43. package/dist/core/logger.js +33 -7
  44. package/dist/core/personalization.d.ts +30 -10
  45. package/dist/core/personalization.js +64 -24
  46. package/dist/core/preference-fields.d.ts +47 -0
  47. package/dist/core/preference-fields.js +180 -0
  48. package/dist/core/probe-repo-file.d.ts +47 -0
  49. package/dist/core/probe-repo-file.js +57 -0
  50. package/dist/core/repo-health.js +40 -32
  51. package/dist/core/roadmap.js +26 -22
  52. package/dist/core/schemas.d.ts +148 -26
  53. package/dist/core/schemas.js +83 -17
  54. package/dist/core/search-budget.d.ts +9 -0
  55. package/dist/core/search-budget.js +36 -3
  56. package/dist/core/search-phases.d.ts +4 -21
  57. package/dist/core/search-phases.js +37 -89
  58. package/dist/core/types.d.ts +151 -38
  59. package/dist/core/utils.js +60 -26
  60. package/dist/formatters/human.d.ts +60 -0
  61. package/dist/formatters/human.js +199 -0
  62. package/dist/formatters/markdown.d.ts +10 -0
  63. package/dist/formatters/markdown.js +31 -0
  64. package/dist/index.d.ts +6 -2
  65. package/dist/index.js +8 -0
  66. package/dist/scout.d.ts +75 -12
  67. package/dist/scout.js +265 -26
  68. package/package.json +1 -1
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared scout construction for CLI commands.
3
+ *
4
+ * Picks the persistence mode from `state.preferences.persistence` so the
5
+ * `gist` preference is actually honored (#115). Previously every command
6
+ * hardcoded `provided` mode, leaving gist sync unreachable no matter what
7
+ * `oss-scout config set persistence gist` wrote.
8
+ */
9
+ import type { ScoutState } from "../core/schemas.js";
10
+ import { type OssScout } from "../scout.js";
11
+ /**
12
+ * Build a scout for a CLI command from already-loaded local state and a token.
13
+ *
14
+ * - `persistence: "gist"` preference → gist-backed scout. createScout loads
15
+ * local state itself and merges it with the gist, and `checkpoint()` pushes
16
+ * to the gist. The caller still calls `saveLocalState` to keep the local
17
+ * file fresh as an offline cache.
18
+ * - otherwise → provided-state scout backed by the local file. The command's
19
+ * `saveLocalState` + `checkpoint()` persist locally.
20
+ */
21
+ export declare function buildCommandScout(state: ScoutState, token: string): Promise<OssScout>;
@@ -0,0 +1,21 @@
1
+ import { createScout } from "../scout.js";
2
+ /**
3
+ * Build a scout for a CLI command from already-loaded local state and a token.
4
+ *
5
+ * - `persistence: "gist"` preference → gist-backed scout. createScout loads
6
+ * local state itself and merges it with the gist, and `checkpoint()` pushes
7
+ * to the gist. The caller still calls `saveLocalState` to keep the local
8
+ * file fresh as an offline cache.
9
+ * - otherwise → provided-state scout backed by the local file. The command's
10
+ * `saveLocalState` + `checkpoint()` persist locally.
11
+ */
12
+ export async function buildCommandScout(state, token) {
13
+ if (state.preferences.persistence === "gist") {
14
+ return createScout({ githubToken: token, persistence: "gist" });
15
+ }
16
+ return createScout({
17
+ githubToken: token,
18
+ persistence: "provided",
19
+ initialState: state,
20
+ });
21
+ }
@@ -2,82 +2,8 @@
2
2
  * Config command — view and update oss-scout preferences.
3
3
  */
4
4
  import { loadLocalState, saveLocalState } from "../core/local-state.js";
5
- import { ScoutPreferencesSchema, IssueScopeSchema, ProjectCategorySchema, PersistenceModeSchema, SearchStrategySchema, } from "../core/schemas.js";
6
- import { ValidationError } from "../core/errors.js";
7
- const FIELD_CONFIGS = {
8
- languages: { type: "array" },
9
- labels: { type: "array" },
10
- excludeRepos: { type: "array" },
11
- excludeOrgs: { type: "array" },
12
- aiPolicyBlocklist: { type: "array" },
13
- minStars: { type: "number" },
14
- maxIssueAgeDays: { type: "number" },
15
- minRepoScoreThreshold: { type: "number" },
16
- interPhaseDelayMs: { type: "number" },
17
- includeDocIssues: { type: "boolean" },
18
- scope: { type: "enum-array", validValues: IssueScopeSchema.options },
19
- projectCategories: {
20
- type: "enum-array",
21
- validValues: ProjectCategorySchema.options,
22
- },
23
- persistence: { type: "enum", validValues: PersistenceModeSchema.options },
24
- defaultStrategy: {
25
- type: "enum-array",
26
- validValues: SearchStrategySchema.options,
27
- },
28
- githubUsername: { type: "string" },
29
- broadPhaseDelayMs: { type: "number" },
30
- skipBroadWhenSufficientResults: { type: "number" },
31
- featuresAnchorThreshold: { type: "number" },
32
- featuresSplitRatio: { type: "float" },
33
- };
34
- function parseBoolean(value) {
35
- const lower = value.toLowerCase();
36
- if (lower === "true" || lower === "yes")
37
- return true;
38
- if (lower === "false" || lower === "no")
39
- return false;
40
- throw new ValidationError(`Invalid boolean value: "${value}". Use true/false or yes/no.`);
41
- }
42
- function parseNumber(value, key) {
43
- const num = parseInt(value, 10);
44
- if (isNaN(num)) {
45
- throw new ValidationError(`Invalid number for "${key}": "${value}"`);
46
- }
47
- return num;
48
- }
49
- function parseFloat(value, key) {
50
- const num = Number.parseFloat(value);
51
- if (isNaN(num)) {
52
- throw new ValidationError(`Invalid number for "${key}": "${value}"`);
53
- }
54
- return num;
55
- }
56
- function parseArrayValue(value) {
57
- return value
58
- .split(",")
59
- .map((s) => s.trim())
60
- .filter((s) => s.length > 0);
61
- }
62
- /**
63
- * Apply an array update: plain set, +append, or -remove.
64
- */
65
- function updateArray(current, value) {
66
- if (value.startsWith("+")) {
67
- const toAdd = parseArrayValue(value.slice(1));
68
- const merged = [...current];
69
- for (const item of toAdd) {
70
- if (!merged.includes(item))
71
- merged.push(item);
72
- }
73
- return merged;
74
- }
75
- if (value.startsWith("-")) {
76
- const toRemove = new Set(parseArrayValue(value.slice(1)));
77
- return current.filter((item) => !toRemove.has(item));
78
- }
79
- return parseArrayValue(value);
80
- }
5
+ import { ScoutPreferencesSchema } from "../core/schemas.js";
6
+ import { applyPreferenceField } from "../core/preference-fields.js";
81
7
  function formatArray(arr) {
82
8
  return arr.length > 0 ? arr.join(", ") : "(none)";
83
9
  }
@@ -103,8 +29,13 @@ export function runConfigShow() {
103
29
  console.log(` aiPolicyBlocklist: ${formatArray(prefs.aiPolicyBlocklist)}`);
104
30
  console.log(` defaultStrategy: ${prefs.defaultStrategy ? formatArray(prefs.defaultStrategy) : "(all)"}`);
105
31
  console.log(` persistence: ${prefs.persistence}`);
32
+ console.log(` preferLanguages: ${formatArray(prefs.preferLanguages)}`);
33
+ console.log(` preferRepos: ${formatArray(prefs.preferRepos)}`);
34
+ console.log(` diversityRatio: ${prefs.diversityRatio}`);
106
35
  console.log(` broadPhaseDelayMs: ${prefs.broadPhaseDelayMs}ms (${(prefs.broadPhaseDelayMs / 1000).toFixed(0)}s)`);
107
36
  console.log(` skipBroadWhenSufficientResults: ${prefs.skipBroadWhenSufficientResults}`);
37
+ console.log(` slmTriageModel: ${prefs.slmTriageModel || "(disabled)"}`);
38
+ console.log(` slmTriageHost: ${prefs.slmTriageHost || "(default 127.0.0.1:11434)"}`);
108
39
  console.log(` featuresAnchorThreshold: ${prefs.featuresAnchorThreshold}`);
109
40
  console.log(` featuresSplitRatio: ${prefs.featuresSplitRatio}`);
110
41
  console.log();
@@ -120,60 +51,10 @@ export function getConfigData() {
120
51
  * Update a single preference by key.
121
52
  */
122
53
  export function runConfigSet(key, value) {
123
- const field = FIELD_CONFIGS[key];
124
- if (!field) {
125
- throw new ValidationError(`Unknown config key: "${key}". Valid keys: ${Object.keys(FIELD_CONFIGS).sort().join(", ")}`);
126
- }
127
54
  const state = loadLocalState();
128
- const prefs = { ...state.preferences };
129
- switch (field.type) {
130
- case "string":
131
- prefs[key] = value;
132
- break;
133
- case "boolean":
134
- prefs[key] = parseBoolean(value);
135
- break;
136
- case "number":
137
- prefs[key] = parseNumber(value, key);
138
- break;
139
- case "float":
140
- prefs[key] = parseFloat(value, key);
141
- break;
142
- case "array": {
143
- const current = prefs[key] ?? [];
144
- prefs[key] = updateArray(current, value);
145
- break;
146
- }
147
- case "enum": {
148
- const validValues = field.validValues;
149
- if (!validValues.includes(value)) {
150
- throw new ValidationError(`Invalid value for "${key}": "${value}". Valid: ${validValues.join(", ")}`);
151
- }
152
- prefs[key] = value;
153
- break;
154
- }
155
- case "enum-array": {
156
- const current = prefs[key] ?? [];
157
- const updated = updateArray(current, value);
158
- const validValues = field.validValues;
159
- const invalid = updated.filter((s) => !validValues.includes(s));
160
- if (invalid.length > 0) {
161
- throw new ValidationError(`Invalid value(s) for "${key}": ${invalid.join(", ")}. Valid: ${validValues.join(", ")}`);
162
- }
163
- // For 'scope', empty array means undefined (all scopes)
164
- if (key === "scope") {
165
- prefs[key] =
166
- updated.length > 0 ? updated : undefined;
167
- }
168
- else {
169
- prefs[key] = updated;
170
- }
171
- break;
172
- }
173
- }
174
- // Validate the full preferences object
175
- const validated = ScoutPreferencesSchema.parse(prefs);
55
+ const validated = applyPreferenceField(state.preferences, key, value);
176
56
  state.preferences = validated;
57
+ state.preferencesUpdatedAt = new Date().toISOString(); // #117 merge recency
177
58
  saveLocalState(state);
178
59
  return validated;
179
60
  }
@@ -184,6 +65,7 @@ export function runConfigReset() {
184
65
  const state = loadLocalState();
185
66
  const defaults = ScoutPreferencesSchema.parse({});
186
67
  state.preferences = defaults;
68
+ state.preferencesUpdatedAt = new Date().toISOString(); // #117 merge recency
187
69
  saveLocalState(state);
188
70
  return defaults;
189
71
  }
@@ -1,9 +1,7 @@
1
1
  /**
2
2
  * Features command — surfaces feature opportunities in anchor repos.
3
3
  */
4
- import { createScout } from "../scout.js";
5
- import { requireGitHubToken } from "../core/utils.js";
6
- import { saveLocalState } from "../core/local-state.js";
4
+ import { withScout } from "./with-scout.js";
7
5
  import { isLinkedPRStalled } from "../core/linked-pr.js";
8
6
  function mapLinkedPR(c) {
9
7
  const linkedPR = c.vettingResult.linkedPR;
@@ -48,29 +46,18 @@ function mapBiggerBet(c) {
48
46
  };
49
47
  }
50
48
  export async function runFeatures(options) {
51
- const token = requireGitHubToken();
52
- const scout = options.state
53
- ? await createScout({
54
- githubToken: token,
55
- persistence: "provided",
56
- initialState: options.state,
57
- })
58
- : await createScout({ githubToken: token });
59
- const result = await scout.features({
60
- count: options.maxResults,
61
- anchorThreshold: options.anchorThreshold,
62
- splitRatio: options.splitRatio,
63
- broad: options.broad,
64
- });
65
- saveLocalState(scout.getState());
66
- const persisted = await scout.checkpoint();
67
- if (!persisted) {
68
- console.error("Warning: changes saved locally but gist sync failed.");
69
- }
70
- return {
71
- quickWins: result.quickWins.map(mapQuickWin),
72
- biggerBets: result.biggerBets.map(mapBiggerBet),
73
- anchorRepos: result.anchorRepos,
74
- message: result.message,
75
- };
49
+ return withScout(options.state, async (scout) => {
50
+ const result = await scout.features({
51
+ count: options.maxResults,
52
+ anchorThreshold: options.anchorThreshold,
53
+ splitRatio: options.splitRatio,
54
+ broad: options.broad,
55
+ });
56
+ return {
57
+ quickWins: result.quickWins.map(mapQuickWin),
58
+ biggerBets: result.biggerBets.map(mapBiggerBet),
59
+ anchorRepos: result.anchorRepos,
60
+ message: result.message,
61
+ };
62
+ }, { persist: true });
76
63
  }
@@ -2,7 +2,18 @@
2
2
  * Results command — display and manage saved search results.
3
3
  */
4
4
  import type { SavedCandidate } from "../core/schemas.js";
5
- export declare function runResults(_options: {
5
+ export interface ResultsOptions {
6
6
  json?: boolean;
7
- }): Promise<SavedCandidate[]>;
7
+ /** Only results first seen at/after this ISO date (or any Date-parseable string). */
8
+ since?: string;
9
+ /** Only results first seen during/after the most recent search run. */
10
+ newOnly?: boolean;
11
+ }
12
+ /**
13
+ * Return saved results, optionally narrowed to "new" ones. `--since` takes an
14
+ * explicit cutoff; `--new-only` uses the last search timestamp so a scheduler
15
+ * can run `search` then `results --new-only` to see just that run's fresh
16
+ * finds (#170). Both compare against each result's `firstSeenAt`.
17
+ */
18
+ export declare function runResults(options?: ResultsOptions): Promise<SavedCandidate[]>;
8
19
  export declare function runResultsClear(): Promise<void>;
@@ -2,9 +2,36 @@
2
2
  * Results command — display and manage saved search results.
3
3
  */
4
4
  import { loadLocalState, saveLocalState } from "../core/local-state.js";
5
- export async function runResults(_options) {
5
+ import { ValidationError } from "../core/errors.js";
6
+ /**
7
+ * Return saved results, optionally narrowed to "new" ones. `--since` takes an
8
+ * explicit cutoff; `--new-only` uses the last search timestamp so a scheduler
9
+ * can run `search` then `results --new-only` to see just that run's fresh
10
+ * finds (#170). Both compare against each result's `firstSeenAt`.
11
+ */
12
+ export async function runResults(options = {}) {
6
13
  const state = loadLocalState();
7
- return state.savedResults ?? [];
14
+ const results = state.savedResults ?? [];
15
+ let cutoff;
16
+ if (options.since !== undefined) {
17
+ const parsed = Date.parse(options.since);
18
+ if (Number.isNaN(parsed)) {
19
+ throw new ValidationError(`Invalid --since date: "${options.since}". Use an ISO date like 2026-06-01.`);
20
+ }
21
+ cutoff = parsed;
22
+ }
23
+ else if (options.newOnly) {
24
+ // No prior search recorded → everything counts as new.
25
+ const parsed = state.lastSearchAt ? Date.parse(state.lastSearchAt) : NaN;
26
+ cutoff = Number.isNaN(parsed) ? undefined : parsed;
27
+ }
28
+ if (cutoff === undefined)
29
+ return results;
30
+ const threshold = cutoff;
31
+ return results.filter((r) => {
32
+ const seen = Date.parse(r.firstSeenAt);
33
+ return !Number.isNaN(seen) && seen >= threshold;
34
+ });
8
35
  }
9
36
  export async function runResultsClear() {
10
37
  const state = loadLocalState();
@@ -64,6 +64,10 @@ interface SearchCommandOptions {
64
64
  preferLanguages?: string[];
65
65
  /** Soft sort boost for candidates in these `owner/repo` slugs (#1244). */
66
66
  preferRepos?: string[];
67
+ /** Soft sort penalty for candidates in these `owner/repo` slugs (#168). */
68
+ avoidRepos?: string[];
69
+ /** Soft sort boost for candidates whose labels match these types (#168). */
70
+ boostIssueTypes?: string[];
67
71
  /** Diversity counterweight: fraction of slots reserved for unboosted candidates (#1244). */
68
72
  diversityRatio?: number;
69
73
  }
@@ -1,76 +1,71 @@
1
1
  /**
2
2
  * Search command — finds contributable issues using multi-strategy search.
3
3
  */
4
- import { createScout } from "../scout.js";
5
- import { requireGitHubToken } from "../core/utils.js";
6
- import { saveLocalState } from "../core/local-state.js";
4
+ import { withScout } from "./with-scout.js";
7
5
  import { isLinkedPRStalled } from "../core/linked-pr.js";
8
6
  export async function runSearch(options) {
9
- const token = requireGitHubToken();
10
- const scout = options.state
11
- ? await createScout({
12
- githubToken: token,
13
- persistence: "provided",
14
- initialState: options.state,
15
- })
16
- : await createScout({ githubToken: token });
17
- const result = await scout.search({
18
- maxResults: options.maxResults,
19
- strategies: options.strategies,
20
- preferLanguages: options.preferLanguages,
21
- preferRepos: options.preferRepos,
22
- diversityRatio: options.diversityRatio,
23
- });
24
- // Persist results to local state and gist
25
- scout.saveResults(result.candidates);
26
- saveLocalState(scout.getState());
27
- const persisted = await scout.checkpoint();
28
- if (!persisted) {
29
- console.error("Warning: changes saved locally but gist sync failed.");
30
- }
31
- return {
32
- candidates: result.candidates.map((c) => {
33
- const repoScoreRecord = scout.getRepoScoreRecord(c.issue.repo);
34
- return {
35
- issue: {
36
- repo: c.issue.repo,
37
- repoUrl: `https://github.com/${c.issue.repo}`,
38
- number: c.issue.number,
39
- title: c.issue.title,
40
- url: c.issue.url,
41
- labels: c.issue.labels,
42
- },
43
- recommendation: c.recommendation,
44
- reasonsToApprove: c.reasonsToApprove,
45
- reasonsToSkip: c.reasonsToSkip,
46
- searchPriority: c.searchPriority,
47
- viabilityScore: c.viabilityScore,
48
- repoScore: repoScoreRecord
49
- ? {
50
- score: repoScoreRecord.score,
51
- mergedPRCount: repoScoreRecord.mergedPRCount,
52
- closedWithoutMergeCount: repoScoreRecord.closedWithoutMergeCount,
53
- isResponsive: repoScoreRecord.signals?.isResponsive ?? false,
54
- lastMergedAt: repoScoreRecord.lastMergedAt,
55
- }
56
- : undefined,
57
- linkedPR: c.vettingResult.linkedPR
58
- ? {
59
- number: c.vettingResult.linkedPR.number,
60
- state: c.vettingResult.linkedPR.state,
61
- url: c.vettingResult.linkedPR.url,
62
- updatedAt: c.vettingResult.linkedPR.updatedAt,
63
- isStalled: isLinkedPRStalled(c.vettingResult.linkedPR),
64
- }
65
- : undefined,
66
- boostScore: c.boostScore,
67
- boostReasons: c.boostReasons,
68
- diversitySlot: c.diversitySlot,
69
- };
70
- }),
71
- excludedRepos: result.excludedRepos,
72
- aiPolicyBlocklist: result.aiPolicyBlocklist,
73
- rateLimitWarning: result.rateLimitWarning,
74
- strategiesUsed: result.strategiesUsed,
75
- };
7
+ return withScout(options.state, async (scout) => {
8
+ const result = await scout.search({
9
+ maxResults: options.maxResults,
10
+ strategies: options.strategies,
11
+ preferLanguages: options.preferLanguages,
12
+ preferRepos: options.preferRepos,
13
+ avoidRepos: options.avoidRepos,
14
+ boostIssueTypes: options.boostIssueTypes,
15
+ diversityRatio: options.diversityRatio,
16
+ });
17
+ scout.saveResults(result.candidates);
18
+ return {
19
+ candidates: result.candidates.map((c) => {
20
+ const repoScoreRecord = scout.getRepoScoreRecord(c.issue.repo);
21
+ return {
22
+ issue: {
23
+ repo: c.issue.repo,
24
+ repoUrl: `https://github.com/${c.issue.repo}`,
25
+ number: c.issue.number,
26
+ title: c.issue.title,
27
+ url: c.issue.url,
28
+ labels: c.issue.labels,
29
+ },
30
+ recommendation: c.recommendation,
31
+ reasonsToApprove: c.reasonsToApprove,
32
+ reasonsToSkip: c.reasonsToSkip,
33
+ searchPriority: c.searchPriority,
34
+ viabilityScore: c.viabilityScore,
35
+ repoScore: repoScoreRecord
36
+ ? {
37
+ score: repoScoreRecord.score,
38
+ mergedPRCount: repoScoreRecord.mergedPRCount,
39
+ closedWithoutMergeCount: repoScoreRecord.closedWithoutMergeCount,
40
+ isResponsive: repoScoreRecord.signals?.isResponsive ?? false,
41
+ lastMergedAt: repoScoreRecord.lastMergedAt,
42
+ }
43
+ : undefined,
44
+ linkedPR: c.vettingResult.linkedPR
45
+ ? {
46
+ number: c.vettingResult.linkedPR.number,
47
+ state: c.vettingResult.linkedPR.state,
48
+ url: c.vettingResult.linkedPR.url,
49
+ updatedAt: c.vettingResult.linkedPR.updatedAt,
50
+ isStalled: isLinkedPRStalled(c.vettingResult.linkedPR),
51
+ }
52
+ : undefined,
53
+ // Keep the JSON output shape stable across the #158 internal
54
+ // refactor: derive the flat boost fields from the structural
55
+ // `personalization` marker.
56
+ boostScore: c.personalization?.kind === "boosted"
57
+ ? c.personalization.score
58
+ : undefined,
59
+ boostReasons: c.personalization?.kind === "boosted"
60
+ ? c.personalization.reasons
61
+ : undefined,
62
+ diversitySlot: c.personalization?.kind === "diversity" ? true : undefined,
63
+ };
64
+ }),
65
+ excludedRepos: result.excludedRepos,
66
+ aiPolicyBlocklist: result.aiPolicyBlocklist,
67
+ rateLimitWarning: result.rateLimitWarning,
68
+ strategiesUsed: result.strategiesUsed,
69
+ };
70
+ }, { persist: true });
76
71
  }
@@ -5,6 +5,8 @@ import type { ScoutPreferences } from "../core/schemas.js";
5
5
  interface ReadlineInterface {
6
6
  question(query: string, callback: (answer: string) => void): void;
7
7
  close(): void;
8
+ on?(event: "close", listener: () => void): unknown;
9
+ off?(event: "close", listener: () => void): unknown;
8
10
  }
9
11
  export interface SetupOptions {
10
12
  rl?: ReadlineInterface;
@@ -4,17 +4,36 @@
4
4
  import * as readline from "readline";
5
5
  import { execFile } from "child_process";
6
6
  import { ScoutPreferencesSchema, ProjectCategorySchema, IssueScopeSchema, } from "../core/schemas.js";
7
+ import { ConfigurationError } from "../core/errors.js";
7
8
  const ALL_CATEGORIES = ProjectCategorySchema.options;
8
9
  const ALL_SCOPES = IssueScopeSchema.options;
9
10
  function createReadlineInterface() {
11
+ // Prompts echo on stderr so stdout stays pure for --json output (#131)
10
12
  return readline.createInterface({
11
13
  input: process.stdin,
12
- output: process.stdout,
14
+ output: process.stderr,
13
15
  });
14
16
  }
15
17
  function ask(rl, query) {
16
- return new Promise((resolve) => {
17
- rl.question(query, (answer) => resolve(answer.trim()));
18
+ return new Promise((resolve, reject) => {
19
+ // Piped stdin that ends early used to leave the pending question
20
+ // unresolved: the event loop drained and the process exited 0 without
21
+ // saving anything (#131). Reject on close instead.
22
+ let settled = false;
23
+ const onClose = () => {
24
+ if (settled)
25
+ return;
26
+ settled = true;
27
+ reject(new ConfigurationError("Input ended before setup finished; preferences were not saved"));
28
+ };
29
+ rl.on?.("close", onClose);
30
+ rl.question(query, (answer) => {
31
+ if (settled)
32
+ return;
33
+ settled = true;
34
+ rl.off?.("close", onClose);
35
+ resolve(answer.trim());
36
+ });
18
37
  });
19
38
  }
20
39
  function detectGitHubUsername() {
@@ -45,12 +64,19 @@ function parseMultiSelect(input, options) {
45
64
  * Run the interactive setup flow and return the configured preferences.
46
65
  */
47
66
  export async function runSetup(options) {
67
+ // Fail fast in non-interactive contexts (CI, piped stdin): prompting
68
+ // would hang or silently save defaults (#131). Injected rl (tests, hosts)
69
+ // opts out of the guard.
70
+ if (!options?.rl && !process.stdin.isTTY) {
71
+ throw new ConfigurationError("setup is interactive and requires a terminal. Use `oss-scout config set <key> <value>` for non-interactive configuration.");
72
+ }
48
73
  const rl = options?.rl ?? createReadlineInterface();
49
74
  const detect = options?.detectUsername ?? detectGitHubUsername;
50
75
  try {
51
- console.log("\n🔧 oss-scout setup\n");
76
+ // Interactive chrome goes to stderr; stdout stays pure for --json (#131)
77
+ console.error("\n🔧 oss-scout setup\n");
52
78
  // Detect GitHub username
53
- console.log("Detecting GitHub username...");
79
+ console.error("Detecting GitHub username...");
54
80
  const detectedUsername = await detect();
55
81
  const usernameDefault = detectedUsername || "";
56
82
  const usernamePrompt = detectedUsername
@@ -84,6 +110,8 @@ export async function runSetup(options) {
84
110
  // Repos to exclude
85
111
  const excludeInput = await ask(rl, "Repos to exclude (owner/repo, comma-separated, optional): ");
86
112
  const excludeRepos = parseCSV(excludeInput);
113
+ // Optional local SLM pre-triage (Ollama). Empty leaves it disabled.
114
+ const slmTriageModel = await ask(rl, "Local SLM triage model for faster pre-filtering (Ollama model id, e.g. gemma4:e4b, optional): ");
87
115
  const prefs = ScoutPreferencesSchema.parse({
88
116
  githubUsername,
89
117
  languages,
@@ -92,8 +120,9 @@ export async function runSetup(options) {
92
120
  excludeRepos,
93
121
  projectCategories,
94
122
  minStars: isNaN(minStars) ? 50 : minStars,
123
+ slmTriageModel,
95
124
  });
96
- console.log("\n✅ Setup complete! Preferences saved.\n");
125
+ console.error("\n✅ Setup complete! Preferences saved.\n");
97
126
  return prefs;
98
127
  }
99
128
  finally {
@@ -25,6 +25,10 @@ export declare function runSkipList(options?: {
25
25
  export declare function runSkipClear(): Promise<void>;
26
26
  /**
27
27
  * Remove a specific issue from the skip list (unskip).
28
+ *
29
+ * Deliberately does NOT validate the URL: entries stored before skip-add
30
+ * validation existed may be junk, and exact-match removal is the only way
31
+ * to clean them up short of `skip clear`.
28
32
  */
29
33
  export declare function runSkipRemove(options: {
30
34
  issueUrl: string;