@oss-scout/core 0.10.0 → 1.0.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 (64) hide show
  1. package/dist/cli.bundle.cjs +77 -60
  2. package/dist/cli.js +403 -416
  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 +7 -0
  10. package/dist/commands/search.js +63 -68
  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 +4 -5
  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 +3 -0
  33. package/dist/core/issue-discovery.js +51 -31
  34. package/dist/core/issue-eligibility.d.ts +10 -4
  35. package/dist/core/issue-eligibility.js +119 -67
  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 +105 -8
  39. package/dist/core/issue-vetting.js +234 -107
  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 +51 -18
  45. package/dist/core/personalization.js +101 -27
  46. package/dist/core/preference-fields.d.ts +47 -0
  47. package/dist/core/preference-fields.js +178 -0
  48. package/dist/core/repo-health.js +31 -15
  49. package/dist/core/roadmap.js +17 -3
  50. package/dist/core/schemas.d.ts +144 -26
  51. package/dist/core/schemas.js +74 -17
  52. package/dist/core/search-budget.d.ts +9 -0
  53. package/dist/core/search-budget.js +36 -3
  54. package/dist/core/search-phases.d.ts +0 -18
  55. package/dist/core/search-phases.js +27 -82
  56. package/dist/core/types.d.ts +146 -30
  57. package/dist/core/utils.js +60 -26
  58. package/dist/formatters/markdown.d.ts +10 -0
  59. package/dist/formatters/markdown.js +31 -0
  60. package/dist/index.d.ts +6 -2
  61. package/dist/index.js +8 -0
  62. package/dist/scout.d.ts +59 -10
  63. package/dist/scout.js +244 -19
  64. package/package.json +1 -1
package/dist/scout.d.ts CHANGED
@@ -4,10 +4,10 @@
4
4
  * Provides personalized issue discovery, vetting, and scoring.
5
5
  * Implements ScoutStateReader to bridge state with the search engine.
6
6
  */
7
- import type { ScoutStateReader } from "./core/issue-vetting.js";
7
+ import type { ScoutStateReader, ScoutStateWriter, SLMConfig } from "./core/issue-vetting.js";
8
8
  import { type FeatureSearchResult } from "./core/feature-discovery.js";
9
9
  import type { ScoutState, ScoutPreferences, RepoScore, SavedCandidate, SkippedIssue, Horizon } from "./core/schemas.js";
10
- import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectCategory, VetListOptions, VetListResult } from "./core/types.js";
10
+ import type { ScoutConfig, SearchOptions, SearchResult, IssueCandidate, MergedPRRecord, ClosedPRRecord, OpenPRRecord, RepoScoreUpdate, ProjectCategory, SyncResult, VetListOptions, VetListResult } from "./core/types.js";
11
11
  import { GistStateStore } from "./core/gist-state-store.js";
12
12
  /**
13
13
  * Create an OssScout instance.
@@ -37,17 +37,43 @@ export declare function createScout(config: ScoutConfig): Promise<OssScout>;
37
37
  * Implements ScoutStateReader so the search engine can read state
38
38
  * without knowing about the persistence layer.
39
39
  */
40
- export declare class OssScout implements ScoutStateReader {
40
+ export declare class OssScout implements ScoutStateReader, ScoutStateWriter {
41
41
  private githubToken;
42
42
  private gistStore;
43
43
  private state;
44
44
  private dirty;
45
- constructor(githubToken: string, initialState: ScoutState, gistStore?: GistStateStore | null);
45
+ /** When true, checkpoint() also writes ~/.oss-scout/state.json. */
46
+ private persistLocal;
47
+ constructor(githubToken: string, initialState: ScoutState, gistStore?: GistStateStore | null, opts?: {
48
+ persistLocal?: boolean;
49
+ });
50
+ /**
51
+ * Drop stale disk-cache entries. Called at the top of every cache-burning
52
+ * entry point (search, features, vetList); without it ~/.oss-scout/cache
53
+ * grows without bound. evictStale never throws (fs errors degrade to warn).
54
+ */
55
+ private evictStaleCacheEntries;
46
56
  /**
47
57
  * Multi-strategy issue search. Returns scored, sorted candidates.
48
58
  * Automatically culls expired skip entries and filters skipped issues.
49
59
  */
50
60
  search(options?: SearchOptions): Promise<SearchResult>;
61
+ /**
62
+ * Populate the `hasActiveMaintainers` repo-score signal from a freshly
63
+ * computed projectHealth (#167). It was initialized false and never set, so
64
+ * calculateScore's +1 active-maintainers weight was inert; `isActive`
65
+ * (recent commit activity) is a real, already-computed proxy.
66
+ *
67
+ * `isResponsive` and `avgResponseDays` are deliberately NOT set here:
68
+ * `projectHealth.avgIssueResponseDays` is a hardcoded `0` placeholder
69
+ * (repo-health.ts), so deriving responsiveness from it would award +1 to
70
+ * every repo — a fake signal worse than the inert one. Real responsiveness
71
+ * needs an actual response-time measurement (extra API calls), deferred.
72
+ * `hasHostileComments` likewise stays a host-settable capability (it needs
73
+ * comment sentiment, out of scope). A failed health check is skipped so its
74
+ * neutral-default fields don't pollute the score.
75
+ */
76
+ private updateRepoSignalsFromHealth;
51
77
  /**
52
78
  * Vet a single issue URL for claimability.
53
79
  */
@@ -83,15 +109,21 @@ export declare class OssScout implements ScoutStateReader {
83
109
  getReposWithOpenPRs(): string[];
84
110
  getStarredRepos(): string[];
85
111
  getProjectCategories(): ProjectCategory[];
112
+ /** Configured GitHub username (used to classify own vs competing PRs, #166). */
113
+ getGitHubUsername(): string;
86
114
  getRepoScore(repo: string): number | null;
87
115
  /**
88
- * Optional SLM pre-triage config read from preferences (oss-autopilot#1122).
89
- * Empty `model` disables the call; the vetter treats it as a no-op.
116
+ * Number of the user's PRs closed without merge in this repo (#125).
117
+ * Prefers the tracked repo score; falls back to counting closedPRs so the
118
+ * scoring penalty works even before a score record exists.
119
+ */
120
+ getClosedWithoutMergeCount(repo: string): number;
121
+ /**
122
+ * SLM pre-triage config read from preferences (oss-autopilot#1122). Returns
123
+ * `null` when no `slmTriageModel` is configured — the vetter skips the SLM
124
+ * call entirely (#158).
90
125
  */
91
- getSLMTriageConfig(): {
92
- model: string;
93
- host: string;
94
- };
126
+ getSLMTriageConfig(): SLMConfig | null;
95
127
  /** Get current preferences (read-only). */
96
128
  getPreferences(): Readonly<ScoutPreferences>;
97
129
  /** Get repo score record for a specific repository. */
@@ -110,6 +142,17 @@ export declare class OssScout implements ScoutStateReader {
110
142
  * Open PRs signal active engagement even when nothing is merged yet.
111
143
  */
112
144
  recordOpenPR(pr: OpenPRRecord): void;
145
+ /**
146
+ * Reconcile tracked open PRs against their current GitHub state (#164).
147
+ *
148
+ * `state.openPRs` was append-only — nothing transitioned an open PR to
149
+ * merged/closed, so getReposWithOpenPRs() over-reported forever once a PR
150
+ * merged. This checks each open PR, records merges/closures (which updates
151
+ * the repo score), prunes resolved entries, and checkpoints. Cheaper than a
152
+ * full bootstrap, so a host can call it on daily startup. Transient errors
153
+ * leave the entry in place; auth/rate-limit failures propagate.
154
+ */
155
+ syncOpenPRs(): Promise<SyncResult>;
113
156
  /**
114
157
  * Update repo score with observed signals.
115
158
  */
@@ -138,6 +181,12 @@ export declare class OssScout implements ScoutStateReader {
138
181
  * Clear all saved results.
139
182
  */
140
183
  clearResults(): void;
184
+ /**
185
+ * Record deletion tombstones (#117) so a later gist merge does not
186
+ * resurrect these URLs from another machine's copy. A re-add with a newer
187
+ * timestamp overrides the tombstone in mergeStates.
188
+ */
189
+ private addTombstones;
141
190
  /**
142
191
  * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
143
192
  */
package/dist/scout.js CHANGED
@@ -7,13 +7,13 @@
7
7
  import { IssueDiscovery } from "./core/issue-discovery.js";
8
8
  import { IssueVetter } from "./core/issue-vetting.js";
9
9
  import { discoverFeatures, discoverFeaturesBroad, } from "./core/feature-discovery.js";
10
- import { ScoutStateSchema } from "./core/schemas.js";
11
10
  import { GistStateStore, mergeStates } from "./core/gist-state-store.js";
12
11
  import { getOctokit } from "./core/github.js";
13
- import { loadLocalState } from "./core/local-state.js";
14
- import { warn } from "./core/logger.js";
15
- import { extractRepoFromUrl } from "./core/utils.js";
16
- import { errorMessage, getHttpStatusCode, isRateLimitError, } from "./core/errors.js";
12
+ import { loadLocalState, saveLocalState } from "./core/local-state.js";
13
+ import { warn, setLogLevel } from "./core/logger.js";
14
+ import { extractRepoFromUrl, parseGitHubUrl } from "./core/utils.js";
15
+ import { errorMessage, getHttpStatusCode, isRateLimitError, rethrowIfFatal, } from "./core/errors.js";
16
+ import { getHttpCache } from "./core/http-cache.js";
17
17
  /** Cause-specific user-facing message for degraded (offline) mode. */
18
18
  function offlineModeMessage(reason) {
19
19
  const tail = "Changes will only be saved locally.";
@@ -93,8 +93,13 @@ function toGistOctokit(octokit) {
93
93
  * ```
94
94
  */
95
95
  export async function createScout(config) {
96
+ // Apply the host's log-level preference before any bootstrap chatter (#156).
97
+ if (config.logLevel !== undefined) {
98
+ setLogLevel(config.logLevel);
99
+ }
96
100
  let state;
97
101
  let gistStore = null;
102
+ let persistLocal = false;
98
103
  if (config.persistence === "provided") {
99
104
  state = config.initialState;
100
105
  }
@@ -114,9 +119,15 @@ export async function createScout(config) {
114
119
  }
115
120
  }
116
121
  else {
117
- state = ScoutStateSchema.parse({ version: 1 });
118
- }
119
- return new OssScout(config.githubToken, state, gistStore);
122
+ // Default: local-file persistence. The previous else-branch silently
123
+ // created throwaway in-memory state, so a documented standalone scout
124
+ // (and the MCP server) read no preferences and persisted nothing while
125
+ // checkpoint() reported success (#116). Load the real state and save it
126
+ // on checkpoint.
127
+ state = loadLocalState();
128
+ persistLocal = true;
129
+ }
130
+ return new OssScout(config.githubToken, state, gistStore, { persistLocal });
120
131
  }
121
132
  /**
122
133
  * Main oss-scout class. Provides search, vetting, and state management.
@@ -129,28 +140,57 @@ export class OssScout {
129
140
  gistStore;
130
141
  state;
131
142
  dirty = false;
132
- constructor(githubToken, initialState, gistStore = null) {
143
+ /** When true, checkpoint() also writes ~/.oss-scout/state.json. */
144
+ persistLocal;
145
+ constructor(githubToken, initialState, gistStore = null, opts = {}) {
133
146
  this.githubToken = githubToken;
134
147
  this.gistStore = gistStore;
135
148
  this.state = initialState;
149
+ this.persistLocal = opts.persistLocal ?? false;
136
150
  }
137
151
  // ── Search ──────────────────────────────────────────────────────────
152
+ /**
153
+ * Drop stale disk-cache entries. Called at the top of every cache-burning
154
+ * entry point (search, features, vetList); without it ~/.oss-scout/cache
155
+ * grows without bound. evictStale never throws (fs errors degrade to warn).
156
+ */
157
+ evictStaleCacheEntries() {
158
+ getHttpCache().evictStale();
159
+ }
138
160
  /**
139
161
  * Multi-strategy issue search. Returns scored, sorted candidates.
140
162
  * Automatically culls expired skip entries and filters skipped issues.
141
163
  */
142
164
  async search(options) {
165
+ this.evictStaleCacheEntries();
143
166
  // Auto-cull expired skips before searching
144
167
  this.cullExpiredSkips();
145
168
  const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
146
169
  const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
170
+ // Per-call flags override the persisted personalization defaults (#168).
171
+ // An empty preference array reads as "no boost" just like an absent flag.
172
+ const prefs = this.state.preferences;
173
+ const prefLangs = prefs.preferLanguages ?? [];
174
+ const prefRepos = prefs.preferRepos ?? [];
175
+ const preferLanguages = options?.preferLanguages ??
176
+ (prefLangs.length > 0 ? prefLangs : undefined);
177
+ const preferRepos = options?.preferRepos ?? (prefRepos.length > 0 ? prefRepos : undefined);
178
+ const diversityRatio = options?.diversityRatio ?? prefs.diversityRatio ?? 0;
147
179
  const { candidates, strategiesUsed } = await discovery.searchIssues({
148
180
  maxResults: options?.maxResults,
149
181
  strategies: options?.strategies,
150
182
  skippedUrls,
151
- preferLanguages: options?.preferLanguages,
152
- preferRepos: options?.preferRepos,
183
+ preferLanguages,
184
+ preferRepos,
185
+ diversityRatio,
186
+ interPhaseDelayMs: options?.interPhaseDelayMs,
187
+ broadPhaseDelayMs: options?.broadPhaseDelayMs,
153
188
  });
189
+ // Feed the freshly observed maintainer-responsiveness signals back into the
190
+ // repo scores so the next search ranks responsive/active repos higher (#167).
191
+ for (const c of candidates) {
192
+ this.updateRepoSignalsFromHealth(c.projectHealth);
193
+ }
154
194
  this.state.lastSearchAt = new Date().toISOString();
155
195
  this.dirty = true;
156
196
  return {
@@ -161,6 +201,28 @@ export class OssScout {
161
201
  strategiesUsed,
162
202
  };
163
203
  }
204
+ /**
205
+ * Populate the `hasActiveMaintainers` repo-score signal from a freshly
206
+ * computed projectHealth (#167). It was initialized false and never set, so
207
+ * calculateScore's +1 active-maintainers weight was inert; `isActive`
208
+ * (recent commit activity) is a real, already-computed proxy.
209
+ *
210
+ * `isResponsive` and `avgResponseDays` are deliberately NOT set here:
211
+ * `projectHealth.avgIssueResponseDays` is a hardcoded `0` placeholder
212
+ * (repo-health.ts), so deriving responsiveness from it would award +1 to
213
+ * every repo — a fake signal worse than the inert one. Real responsiveness
214
+ * needs an actual response-time measurement (extra API calls), deferred.
215
+ * `hasHostileComments` likewise stays a host-settable capability (it needs
216
+ * comment sentiment, out of scope). A failed health check is skipped so its
217
+ * neutral-default fields don't pollute the score.
218
+ */
219
+ updateRepoSignalsFromHealth(health) {
220
+ if (health.checkFailed)
221
+ return;
222
+ this.updateRepoScore(health.repo, {
223
+ signals: { hasActiveMaintainers: health.isActive },
224
+ });
225
+ }
164
226
  /**
165
227
  * Vet a single issue URL for claimability.
166
228
  */
@@ -183,6 +245,7 @@ export class OssScout {
183
245
  * preferences and excluded repos/orgs.
184
246
  */
185
247
  async features(options) {
248
+ this.evictStaleCacheEntries();
186
249
  const count = options?.count ?? 10;
187
250
  const octokit = getOctokit(this.githubToken);
188
251
  const vetter = new IssueVetter(octokit, this);
@@ -217,6 +280,7 @@ export class OssScout {
217
280
  * Optionally prunes unavailable issues from saved results.
218
281
  */
219
282
  async vetList(options) {
283
+ this.evictStaleCacheEntries();
220
284
  const saved = this.getSavedResults();
221
285
  const concurrency = options?.concurrency ?? 5;
222
286
  const results = [];
@@ -238,6 +302,7 @@ export class OssScout {
238
302
  number: item.number,
239
303
  title: item.title,
240
304
  status: this.classifyVetResult(candidate),
305
+ ok: true,
241
306
  recommendation: candidate.recommendation,
242
307
  viabilityScore: candidate.viabilityScore,
243
308
  });
@@ -255,6 +320,7 @@ export class OssScout {
255
320
  number: item.number,
256
321
  title: item.title,
257
322
  status: isGone ? "closed" : "error",
323
+ ok: false,
258
324
  errorMessage: errorMessage(error),
259
325
  });
260
326
  })
@@ -282,6 +348,33 @@ export class OssScout {
282
348
  hasPR: results.filter((r) => r.status === "has_pr").length,
283
349
  errors: results.filter((r) => r.status === "error").length,
284
350
  };
351
+ // Claim-watch (#165): compare each result's current status to the status
352
+ // recorded on the saved result last time, then persist the new status so
353
+ // the next run can diff again. "error" is transient — never a transition
354
+ // target and never stored.
355
+ const prevStatus = new Map((this.state.savedResults ?? []).map((r) => [r.issueUrl, r.lastStatus]));
356
+ const transitions = results
357
+ .filter((r) => r.status !== "error")
358
+ .filter((r) => {
359
+ const prev = prevStatus.get(r.issueUrl);
360
+ return prev !== undefined && prev !== r.status;
361
+ })
362
+ .map((r) => ({
363
+ issueUrl: r.issueUrl,
364
+ repo: r.repo,
365
+ number: r.number,
366
+ from: prevStatus.get(r.issueUrl),
367
+ to: r.status,
368
+ }));
369
+ const currentStatus = new Map(results.map((r) => [r.issueUrl, r.status]));
370
+ for (const saved of this.state.savedResults ?? []) {
371
+ const status = currentStatus.get(saved.issueUrl);
372
+ if (status !== undefined && status !== "error") {
373
+ saved.lastStatus = status;
374
+ }
375
+ }
376
+ if (results.length > 0)
377
+ this.dirty = true;
285
378
  let prunedCount;
286
379
  if (options?.prune) {
287
380
  const unavailableUrls = new Set(results
@@ -290,11 +383,18 @@ export class OssScout {
290
383
  const before = (this.state.savedResults ?? []).length;
291
384
  this.state.savedResults = (this.state.savedResults ?? []).filter((r) => !unavailableUrls.has(r.issueUrl));
292
385
  prunedCount = before - (this.state.savedResults?.length ?? 0);
386
+ if (prunedCount > 0)
387
+ this.addTombstones([...unavailableUrls]);
293
388
  this.dirty = true;
294
389
  }
295
- return { results, summary, prunedCount };
390
+ return { results, summary, prunedCount, transitions };
296
391
  }
297
392
  classifyVetResult(candidate) {
393
+ // Closed wins over everything: GitHub returns 200 for closed issues, so
394
+ // the 404/410 catch path alone never saw them (#120). Candidates cached
395
+ // by older versions lack issueState and read as open.
396
+ if (candidate.issueState === "closed")
397
+ return "closed";
298
398
  const checks = candidate.vettingResult.checks;
299
399
  if (!checks.noExistingPR)
300
400
  return "has_pr";
@@ -334,19 +434,35 @@ export class OssScout {
334
434
  getProjectCategories() {
335
435
  return this.state.preferences.projectCategories;
336
436
  }
437
+ /** Configured GitHub username (used to classify own vs competing PRs, #166). */
438
+ getGitHubUsername() {
439
+ return this.state.preferences.githubUsername;
440
+ }
337
441
  getRepoScore(repo) {
338
442
  const score = this.state.repoScores[repo];
339
443
  return score ? score.score : null;
340
444
  }
341
445
  /**
342
- * Optional SLM pre-triage config read from preferences (oss-autopilot#1122).
343
- * Empty `model` disables the call; the vetter treats it as a no-op.
446
+ * Number of the user's PRs closed without merge in this repo (#125).
447
+ * Prefers the tracked repo score; falls back to counting closedPRs so the
448
+ * scoring penalty works even before a score record exists.
449
+ */
450
+ getClosedWithoutMergeCount(repo) {
451
+ const score = this.state.repoScores[repo];
452
+ if (score)
453
+ return score.closedWithoutMergeCount;
454
+ return (this.state.closedPRs ?? []).filter((p) => extractRepoFromUrl(p.url) === repo).length;
455
+ }
456
+ /**
457
+ * SLM pre-triage config read from preferences (oss-autopilot#1122). Returns
458
+ * `null` when no `slmTriageModel` is configured — the vetter skips the SLM
459
+ * call entirely (#158).
344
460
  */
345
461
  getSLMTriageConfig() {
346
- return {
347
- model: this.state.preferences.slmTriageModel ?? "",
348
- host: this.state.preferences.slmTriageHost ?? "",
349
- };
462
+ const model = this.state.preferences.slmTriageModel ?? "";
463
+ if (!model)
464
+ return null;
465
+ return { model, host: this.state.preferences.slmTriageHost ?? "" };
350
466
  }
351
467
  /** Get current preferences (read-only). */
352
468
  getPreferences() {
@@ -401,6 +517,78 @@ export class OssScout {
401
517
  ];
402
518
  this.dirty = true;
403
519
  }
520
+ /**
521
+ * Reconcile tracked open PRs against their current GitHub state (#164).
522
+ *
523
+ * `state.openPRs` was append-only — nothing transitioned an open PR to
524
+ * merged/closed, so getReposWithOpenPRs() over-reported forever once a PR
525
+ * merged. This checks each open PR, records merges/closures (which updates
526
+ * the repo score), prunes resolved entries, and checkpoints. Cheaper than a
527
+ * full bootstrap, so a host can call it on daily startup. Transient errors
528
+ * leave the entry in place; auth/rate-limit failures propagate.
529
+ */
530
+ async syncOpenPRs() {
531
+ const octokit = getOctokit(this.githubToken);
532
+ const open = this.state.openPRs ?? [];
533
+ const result = {
534
+ checked: open.length,
535
+ merged: 0,
536
+ closed: 0,
537
+ stillOpen: 0,
538
+ errors: 0,
539
+ };
540
+ const remaining = [];
541
+ for (const pr of open) {
542
+ const parsed = parseGitHubUrl(pr.url);
543
+ if (!parsed || parsed.type !== "pull") {
544
+ remaining.push(pr);
545
+ result.errors++;
546
+ continue;
547
+ }
548
+ const repoFullName = `${parsed.owner}/${parsed.repo}`;
549
+ try {
550
+ const { data } = await octokit.pulls.get({
551
+ owner: parsed.owner,
552
+ repo: parsed.repo,
553
+ pull_number: parsed.number,
554
+ });
555
+ if (data.merged) {
556
+ this.recordMergedPR({
557
+ url: pr.url,
558
+ title: pr.title,
559
+ mergedAt: data.merged_at ?? new Date().toISOString(),
560
+ repo: repoFullName,
561
+ });
562
+ result.merged++;
563
+ }
564
+ else if (data.state === "closed") {
565
+ this.recordClosedPR({
566
+ url: pr.url,
567
+ title: pr.title,
568
+ closedAt: data.closed_at ?? new Date().toISOString(),
569
+ repo: repoFullName,
570
+ });
571
+ result.closed++;
572
+ }
573
+ else {
574
+ remaining.push(pr);
575
+ result.stillOpen++;
576
+ }
577
+ }
578
+ catch (err) {
579
+ // Auth/rate-limit aborts the whole sync; a transient/404 leaves the
580
+ // entry untouched so a later sync can retry rather than losing it.
581
+ rethrowIfFatal(err);
582
+ warn("scout", `sync: could not check ${pr.url}: ${errorMessage(err)}`);
583
+ remaining.push(pr);
584
+ result.errors++;
585
+ }
586
+ }
587
+ this.state.openPRs = remaining;
588
+ this.dirty = true;
589
+ await this.checkpoint();
590
+ return result;
591
+ }
404
592
  /**
405
593
  * Update repo score with observed signals.
406
594
  */
@@ -436,6 +624,9 @@ export class OssScout {
436
624
  */
437
625
  updatePreferences(updates) {
438
626
  this.state.preferences = { ...this.state.preferences, ...updates };
627
+ // Stamp so the gist merge keeps the fresher preferences instead of
628
+ // always taking the remote copy (#117).
629
+ this.state.preferencesUpdatedAt = new Date().toISOString();
439
630
  this.dirty = true;
440
631
  }
441
632
  /**
@@ -485,9 +676,25 @@ export class OssScout {
485
676
  * Clear all saved results.
486
677
  */
487
678
  clearResults() {
679
+ this.addTombstones((this.state.savedResults ?? []).map((r) => r.issueUrl));
488
680
  this.state.savedResults = [];
489
681
  this.dirty = true;
490
682
  }
683
+ /**
684
+ * Record deletion tombstones (#117) so a later gist merge does not
685
+ * resurrect these URLs from another machine's copy. A re-add with a newer
686
+ * timestamp overrides the tombstone in mergeStates.
687
+ */
688
+ addTombstones(urls) {
689
+ if (urls.length === 0)
690
+ return;
691
+ const removedAt = new Date().toISOString();
692
+ const existing = this.state.tombstones ?? [];
693
+ const byUrl = new Map(existing.map((t) => [t.url, t]));
694
+ for (const url of urls)
695
+ byUrl.set(url, { url, removedAt });
696
+ this.state.tombstones = [...byUrl.values()];
697
+ }
491
698
  // ── Skip List ───────────────────────────────────────────────────────
492
699
  /**
493
700
  * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
@@ -506,7 +713,10 @@ export class OssScout {
506
713
  skippedAt: new Date().toISOString(),
507
714
  },
508
715
  ];
509
- // Also remove from saved results if present
716
+ // Also remove from saved results if present. No tombstone needed: the
717
+ // skip entry itself is the durable record, and mergeStates reconciles
718
+ // saved results against the skip list so a merge can't resurrect a
719
+ // skipped URL into the saved list (#117).
510
720
  if (this.state.savedResults) {
511
721
  this.state.savedResults = this.state.savedResults.filter((r) => r.issueUrl !== url);
512
722
  }
@@ -522,13 +732,17 @@ export class OssScout {
522
732
  * Remove a specific issue from the skip list.
523
733
  */
524
734
  unskipIssue(url) {
735
+ const before = (this.state.skippedIssues ?? []).length;
525
736
  this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url);
737
+ if (this.state.skippedIssues.length < before)
738
+ this.addTombstones([url]);
526
739
  this.dirty = true;
527
740
  }
528
741
  /**
529
742
  * Clear all skipped issues.
530
743
  */
531
744
  clearSkippedIssues() {
745
+ this.addTombstones((this.state.skippedIssues ?? []).map((s) => s.url));
532
746
  this.state.skippedIssues = [];
533
747
  this.dirty = true;
534
748
  }
@@ -572,6 +786,17 @@ export class OssScout {
572
786
  if (!ok)
573
787
  return false;
574
788
  }
789
+ if (this.persistLocal) {
790
+ // Honest persistence: in local mode the previous no-op return true
791
+ // claimed success while saving nothing (#116). A failed write keeps
792
+ // the dirty flag and reports failure.
793
+ try {
794
+ saveLocalState(this.state);
795
+ }
796
+ catch {
797
+ return false;
798
+ }
799
+ }
575
800
  this.dirty = false;
576
801
  return true;
577
802
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/core",
3
- "version": "0.10.0",
3
+ "version": "1.0.0",
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": {